Add SSH certificate authentication for targets, issued by Vault - #2397
Add SSH certificate authentication for targets, issued by Vault#2397janisdombr wants to merge 63 commits into
Conversation
|
Will be happy to see this merged since it's the only deployment blocker for us due to security concerns. |
050912e to
1578fc6
Compare
|
Went through this again against the current head ( Real progress since the last round: the AWS static-credential gap is now a genuine, well-designed fix, Three things from the last round are still open, each with a concrete fix. Vault issuer errors reaching the SSH client are still truncated to 256 characters rather than sanitized by content, so a policy or role name can survive that length. The fix is a
Also, The bigger thing: stepping back from individual lines, there's a structural question worth resolving before this merges. Under the current design, Vault can't distinguish one target/session from another, One more thing worth knowing before this merges: #2185 also adds Vault integration (a different problem, relocating static secrets into KV rather than issuing certs, but it collides mechanically with this PR in several places, workspace crate registration, This doesn't mean the direction is wrong. Ephemeral, non-stored credentials is the right fix for a real, long-standing gap, and the mechanics here are solid. |
|
Opened #2400 with a concrete design for the authorization question from the review above, rather than posting the whole thing inline here. Short version: the core piece there, identity-templated Vault roles plus per-session scoped child tokens, so Vault verifies the principal instead of trusting what Warpgate asserts, belongs in this PR before merge, not a fast-follow. Without it, this design can plausibly have a worse worst-case blast radius than what it replaces (fleet-wide, non-revocable access versus today's bounded-to-stored-credentials), so it's not a good candidate for shipping as a documented limitation. The remaining hardening in the issue (full IdP-verified non-repudiation, host-binding, revocation) is genuinely separable follow-up work once that baseline is in. |
c00a253 to
bc0fb04
Compare
|
@theredspoon Thank you for the follow-up review!
|
|
Went through the current head (
let http = reqwest::Client::builder().timeout(config.timeout).build()?;That leaves reqwest's default policy in place, which follows redirects and only strips Unbounded buffering + panic in error-body truncation
let body = response.text().await.unwrap_or_default();
let max_len = 256;
let body = if body.len() > max_len {
format!("{}... (truncated)", &body[..max_len])
} else {
body
};
Smaller items
|
|
@theredspoon both fixed, thanks. Redirects are refused outright now, which covers the metadata calls too. The error body is read chunk-wise with a 256-byte cap and truncated lossily, so a split character can't panic it. Chasing that one, I found the success path had Zeroization is end-to-end now: the login body goes through typed structs instead of a The stub validators actually validate now — decoded AWS payload, full Azure coordinates, JWT shape, GCP audience — and have tests of their own. You were right that they were asserting nothing. A pass over the rest turned up a few more: One I'd like your view on: a role with 425fb05. |
4d8e294 to
dd06172
Compare
|
Confirmed everything in On The "hostile Vault already has target access anyway" framing undersells this. The realistic case day to day is more mundane than either: a legitimate, uncompromised Vault, an operator who copies or templates a role with Suggest: default-reject any critical option. Per-target opt-in as a named allow-list of expected option keys, not a bare boolean, and for Two more, from this round:
expires_at: (auth.lease_duration > 0).then(|| {
Instant::now() + Duration::from_secs(auth.lease_duration).saturating_sub(TOKEN_EXPIRY_MARGIN)
}),
IPv6 loopback is misclassified as insecure. One more, lower priority: the AWS path is the one exception to end-to-end zeroization. |
|
Ran a wider architectural sweep across the codebase, not just this PR's diff, then went back and verified every proposed fix against the real code and this PR's own existing patterns. Certificate minting via the host-key-check admin endpoint
The fix needs to be a deterministic signal, not a race. Separately: Vault config doesn't hot-reload
Cloud metadata tokens can transit an ambient proxy
Checked against Vault's actual server source ( Three real things remain from that investigation:
Response-wrapped AppRole secret IDs need the unwrapped value cached
Response wrapping protects one-time delivery of the secret ID, it doesn't force single-use of the secret ID itself. Please resolve by caching the unwrapped secret ID, keyed on the raw file content, reusing it while the file is unchanged and only re-unwrapping when the content actually changes (an operator writing a fresh wrapping token). Keep a distinct error for the real failure case, an unwrap attempt (first use, or after a detected change) that fails because the token is stale or already consumed: Lower priority
|
409e2e5 to
4b825c1
Compare
|
Both rounds are in commit 409e2e5. @theredspoon On critical options you changed my mind. "A hostile Vault already has target access" conflated two different capabilities: force-command isn't extra access, it's laundered attribution, and the target's own log is the thing this feature exists to make trustworthy. The role-write-without-sign path settles it. So: default-reject, per-target allow-list of names with optional pinned values, and the refusal reaches the connecting user rather than a log nobody watches. Everything else landed as you described it checked_add on the lease, url::Host for IPv6, Zeroizing on the AWS path, the allow_user_key_ids message, valid_principals checked with Two places I'm weaker than I'd like, said plainly: The host-key check I took the explicit-intent route, a dedicated RCCommand::CheckHostKey that returns before authenticate_session, final hop only. What I can demonstrate is the leak: revert it and my test fails on connections still open after the request returned. What I could not reproduce is the certificate actually being minted the leaked task stalls before signing in my setup, over a 5s window. That assertion is a guard, not evidence; your 310.6s measurement is the real data point. If you can share how you drove it to sign I'll make it deterministic. The JoinHandle I didn't thread one through. CheckHostKey ends the task, and the admin caller sends an explicit abort afterwards, scoped so ServerSession's graceful disconnect stays untouched. Two mechanisms rather than the third you Tests are 15 Rust unit and 57 integration, up from 12 and 48. Each new one was verified by breaking the code it defends including one that didn't fail on the first attempt, the valid_principals case, which rejects that certificate too. Rewritten to assert who did the refusing. |
Warpgate authenticates to an SSH target with a short-lived OpenSSH user certificate signed on demand by HashiCorp Vault, instead of a private key it stores. The ephemeral keypair is generated per connection and never persisted, so a compromise of the Warpgate host yields nothing a target would accept. Targets trust the CA through TrustedUserCAKeys and need no authorized_keys. The certificate's key ID carries the Warpgate username and session UUID, so the target's own sshd log attributes a proxied session to a person rather than to the gateway. VaultAuth offers workload identity only — kubernetes, AppRole, AWS, Azure and GCP. Each reads its credential from a file or a metadata service, never from the config: a static Vault password would merely relocate the long-lived secret this feature exists to remove. Full compatibility with OpenBao is supported. Verified end to end against real infrastructure — AWS STS, a GCE instance, an Azure VM and a k3d cluster. tests/test_ssh_target_cert_auth.py runs against a stub issuer and needs neither Vault nor a cluster. Discussion: warp-tech#26 Special thanks to @theredspoon for the detailed test, OpenBao evaluation, and security recommendations.
- The admin host-key check ran on into authenticating to the target. On a certificate target that minted a real certificate and opened a real session nobody was attached to, held until the inactivity timeout, with a key ID naming no user. Now a dedicated RCCommand::CheckHostKey stops before authentication, on the final hop only so jump hosts still authenticate. - A certificate could arrive carrying critical options nobody asked for. A force-command there replaces what the user typed while keeping their own principal and key ID on the session, so the target's log attributes it to them. Write access to a Vault role is a lower bar than the right to sign with it, so this is the only place it can be caught. Refused by default; a target may name the options it expects and pin their values. - Nothing checked that the certificate named the account being reached. valid_principals is now verified against the target's username. - A response-wrapped AppRole secret ID was re-unwrapped on every login. A wrapping token is single-use, so every login after the first failed, as a generic denial. The unwrapped secret ID is now cached against the file content, and a genuine unwrap failure names the file and the fix. - lease_duration from Vault fed an unchecked Instant addition, so an oversized lease crashed the process on the login path. Now rejected as a bad response. - Cloud metadata tokens went through the same client as Vault, which honours HTTP_PROXY by default; GCE's hostname defeats a typical IP-based NO_PROXY. Metadata now uses a client built with no_proxy(). - The AWS login path was the one place credentials were not zeroized. - An IPv6 loopback Vault address was classified as a remote plaintext endpoint, because host_str renders it with brackets. - Editing the vault: section had no effect until a restart, alone among config sections. A VaultCell on a watch channel is rebuilt from run.rs; a configuration that fails to build keeps the working client. - A certificate Warpgate itself refused reported "SSH target rejected Warpgate's authentication request", naming the wrong party. It has its own error now, and the reason reaches the connecting user. - A role that forbids key IDs now produces a message naming allow_user_key_ids. Tests: 15 Rust unit and 57 integration, up from 12 and 48; each new one verified by breaking the code it defends. The stub models single-use wrapping tokens, without which the AppRole defect was invisible. Found by @theredspoon's review, which is worth more than the code it corrects.
c5dea27 to
58f831a
Compare
The stub in tests/ is fast and can be made to misbehave, but it only knows what we told it — and two of the defects found in review were invisible for exactly as long as it was the only witness. tests/vault_server.py runs the suite against a real HashiCorp Vault and a real OpenBao, reading requests back out of the server's own audit device, so the payload under assertion is the one the server received. Every behaviour the stub models is now pinned against both. Three defects came out of it: - Every login left a copy of the credential in freed memory. login_payload used serde_json::to_string, whose String grows as it is written and frees each smaller buffer without wiping it; Zeroizing only ever wipes the buffer that survives to the end. Size decides whether it shows: measured with a 4 KiB credential, which is what a Kubernetes service account token or a signed AWS header set actually is. Now serialized into a buffer reserved up front. - The certificate's key ID was never checked against the one requested. A certificate carrying a 64 KiB key ID authenticated normally. The target's sshd logs that field verbatim, and "the target's own log names the person" is the claim this path exists to deliver, so an issuer returning a different one breaks attribution silently. - The reason an authentication failed never reached the person connecting. ConnectionError::Authentication carried no detail; the reason went to the server log and the user got a fixed string. For a certificate refused because it is outside its validity window — the documented clock-skew hazard — that sends whoever is debugging it to check credentials that are fine. The variant now carries its reason and the certificate arm names the window. Also documented: OpenBao refuses to enable an audit device over the API, and its config stanza needs type, path and an options block — a top-level file_path is accepted with a warning and then ignored, which looks exactly like a working audit device that writes nothing. Tests: 16 contract tests across Vault and OpenBao (five versions under WARPGATE_VAULT_MATRIX=full), 8 for certificates a real issuer would never emit, 6 property tests over the validators, and 3 that watch the allocator to check the zeroization claim rather than trusting it.
58f831a to
d818090
Compare
|
Pushed d818090, rebased onto current main. This round came from building the test infrastructure rather than from reading the diff again. tests/vault_server.py runs the suite against a real Vault and a real OpenBao, reading requests back out of the server's own audit device, so
Also OpenBao refuses to enable an audit device over the API, and its config stanza needs Two CI gates are red and neither is from this branch:
I left both alone rather than touch unrelated files in a security PR. |
Three defects, found by reading other projects' advisories and by pointing two tools at this code that had not been used on it before. - A certificate naming more than the target account was accepted. The check asked whether the requested principal was among those returned; Vault returns the requested set verbatim or refuses, so anything extra means the answer did not come from this request. Each extra name is another account the target will accept the certificate for, chosen by whoever answered rather than by the operator, and under AuthorizedPrincipalsFile it need not resemble a username. Now required to be exactly the account asked for. This came from CVE-2024-7594, where an empty valid_principals yielded a certificate good for any user on the host, and CVE-2026-35414, where a comma inside a principal splits one name into two for one of sshd's checks and not the other. The second is also why the rule is "exactly one name" rather than "contains": it notes the attack works when the CA does not reject commas in what it is asked to sign, which is the check Warpgate already makes on the request side. - A certificate could write escape sequences to the connecting user's terminal. The refusal message quotes the critical option's name straight out of the certificate and is printed to the PTY, so a name containing \x1b[2J cleared their screen rather than appearing in the text. Certificate-derived strings are now quoted with {:?}. - The outbound SSH handshake had no bound of its own. A target that completes the TCP connection, sends a valid identification string and then goes silent held the gateway's task, socket and session slot until the *inbound* session's inactivity timeout fired — measured at 55s with that timeout set to 45s. That setting governs how long an idle interactive session may live and is legitimately raised to hours, every one of which extended this hold to match. Bounded now by a dedicated 30s deadline, with an error naming the stage so an operator is not sent to look at credentials. tests/hostile_ssh_server.py is new: six ways of being a bad SSH server, none of which needs Docker. The rest of the suite treats the target as honest, which is the one trust boundary nothing here had pushed on — and russh, which Warpgate is the client half of, has published pre-authentication panics reachable from the peer. Five of the six modes were survived without change. cargo mutants found the fourth problem, in the tests rather than the code: it replaced the error-body reader with one returning an empty string and everything still passed, because the assertions were all upper bounds. Ten mutants survived in that one function. The truncation marker is now pinned from both sides.
|
Pushed 6bd00e1. Three more defects, found by reading other projects' advisories and by pointing two tools at this code that had not been used on it before. A certificate naming more than the target account was accepted. The check asked whether the requested principal was among those returned. Vault returns the requested set verbatim or refuses, so anything extra means the answer did not come from this request and each extra name is another account the target will accept the certificate for, chosen by whoever answered rather than by the operator. Under This came out of two advisories rather than out of the diff: CVE-2024-7594, where an empty A certificate could write escape sequences to the connecting user's terminal. The refusal message quotes the critical option's name straight out of the certificate and is printed to the PTY, so a name containing The outbound SSH handshake had no bound of its own. A target that completes the TCP connection, sends a valid identification string and then goes silent held the gateway's task, socket and session slot until the inbound session's inactivity timeout fired measured at 55s with that timeout set to 45s. That setting governs how long an idle interactive session may live and is legitimately raised to hours, every one of which extended this hold to match. Bounded now by a dedicated 30s deadline, with an error that names the stage.
Checked and clean, for the record: russh 0.62.6 is current against all fourteen of its advisories, and CI is still red on |
|
Ran a final-gate pass with three independent reviewers plus direct verification against real sshd servers, since this round changed enough surface (the real-Vault/real-OpenBao test harness, the critical_options allow-list logic, the CheckHostKey command) to be worth a genuinely fresh look rather than re-confirming what's already fixed. Everything from the last round not mentioned below has been confirmed separately. Two real, previously-unflagged issues, plus a cluster of smaller ones. Host-key check returns the wrong key for any target behind a jump host Already independently reported and being fixed: issue #2412 and its open fix, PR #2413 ( What #2413 doesn't cover, since it's written against Related to that: no certificate gets minted for the jump host today, but that's not a construction guarantee the way it is for the final hop, it's the admin endpoint's abort winning a race against the SSH handshake, the same category of fragility Pinned critical options are only checked when the certificate actually carries them
Smaller items, roughly by severity
One more, separate from the above: the terminal-escape-sequence fix in Given how many of the above are tests passing without exercising what they claim to, worth doing your own adversarial pass over the test suite specifically, not just the production code, and writing down whatever gaps that turns up so they don't quietly regress later. |
Until now "the guards discriminate" was a statement about the last afternoon somebody ran the matrix by hand and reported a number. Two of the numbers reported that way were wrong. A green check is the durable form of that claim. `--changed <base>` selects the guards whose anchor file a change touched, and prints how many of how many: a run that reports on a subset has to name the subset, or a green check implies a sweep that never happened. The workflow runs that subset on `pull_request` and all fifty-three on a schedule and on dispatch, the second being the backstop the first leans on. `fetch-depth: 0`, because `--changed` has nothing to diff against otherwise; the script names that case rather than reporting zero changed guards. A guard with no named discriminator fails the build, and every guard that did not discriminate is now named in the failure rather than only counted. The `cargo test -p warpgate-vault` precondition is skipped in `--named` mode. It runs that whole suite — 250 seconds, measured — on every invocation to establish the tree passes before anything is mutated, and `--named` establishes the same thing per guard and more narrowly: the named test must pass *before* the mutation, or the guard is reported `already failing` and no verdict comes out of it. A sweep also printed nothing until every guard had finished, so an hour-long run was indistinguishable from a hung one by anything except `ps`. Two of today's runs were killed for exactly that reason. Each guard now reports as it lands.
Two sweeps ran side by side today. The lock was written unconditionally rather than checked, so the second started happily beside the first and both rewrote the same source files — two mutations live at once, and any verdict either produced would have been about a tree neither of them described. It was noticed by counting processes, which is not a control. A lock left behind by a killed run now blocks a start too, and that is the right way round: a stale lock costs one command to clear, and the refusal names both the command and the check to run first. A contaminated sweep costs a number that looks like evidence. The pre-checks were also silent. They build a test binary per crate and run two workspace-wide `cargo check` passes, which together ran for over an hour on a full sweep while printing nothing at all — so the run was indistinguishable from a hung one, and three of today's were killed on exactly that ambiguity. Each phase now announces itself before it starts.
Raised by the auditor from the sweep's own output, which is the first defect this round found by watching an instrument run rather than by reading it. `verify_named` restored the mutated *source* in its `finally` and never rebuilt the binary compiled from it; the clean rebuild happened once, after the whole loop. So `target/debug/warpgate` carried the last mutation applied to it, and every integration guard's baseline ran the previous guard's mutated gateway. It surfaced as `already failing` on two guards of a sweep whose tree was clean. The consequence is wider than those two. A `discriminates` verdict asserts the named test passed before the mutation and failed after it, and the first half of that was measured against someone else's mutation. The verdicts from that sweep are void rather than partial, and it restarts rather than resumes. The gateway is now rebuilt from clean source before a baseline that needs one, and only then — which turns out to be 16 of the 53 guards. The other 37 are pinned by Rust unit tests, which `cargo test -p` compiles itself and which never look at `target/debug/warpgate`; building it for them was work whose result nothing read. Each guard now prints `baseline`, `building the gateway with the guard off`, and `testing with the guard off` as it reaches them, so a slow guard is legible as slow rather than as stuck. `--fail-fast` stops at the first guard that does not discriminate and says how many remain **unknown, not passing**. Guards added or repointed in round J are ordered first, so that flag reaches the least-established ones in minutes rather than hours. Order changes no verdict: every guard is measured against its own baseline.
The claim this feature's test suite has been asked to support since the first review round, produced in a single sweep rather than assembled from separate afternoons: each guard disabled in turn, and the test named after it failing exactly when the guard is gone and passing when it is not. `tests/mutation-matrix.json` carries `partial: false`, `refused: null`, and 53 results of one status. Fourteen hours and twenty minutes, of which roughly fifty-five minutes were pre-checks. The number is worth what the instrument is worth, and this round found six defects in the instrument. It would not start at all — its own duplicate check tripped on a duplicate. Its documented invocation ran from nowhere. It paid the whole repository's collection cost for a single guard, in three separate places. It printed nothing for hours, so three runs were killed as hung. Two runs executed side by side rewriting the same files, because the lock was written rather than checked. And the mutated gateway leaked into the next guard's baseline, which invalidated 28 verdicts of the preceding sweep — found by the auditor, from a running sweep's output, not from reading the code. All six were fixed before this run, and the run started from scratch rather than resuming, because a sweep half-measured against another guard's mutation is void rather than partial. Guards 29 and 31, the two that produced the false `already failing` that exposed the last of those defects, pass cleanly here. What this does not say: that these are the right guards, or that the set is complete. Neither claim is made.
Fourteen commits, of which the release itself, a vt100 upgrade fixing a resize panic, pubkey failures counting towards IP blocking, and a session view linking to its user and target. Only the two generated OpenAPI schemas conflicted, and they were regenerated from the merged Rust types rather than resolved by hand. That was not ceremony: taking either side wholesale would have dropped upstream's new `user_id`, `target_id` and `remote_address` fields out of the published API, silently, because a hand-resolved generated file corresponds to no version of the types it is generated from. `warpgate-protocol-ssh/src/server/session.rs` merged cleanly despite both sides editing it. No guard's anchor file is touched by any of it, so `--changed` selects nothing and the 53-guard sweep is not repeated. That rule cannot see a guard broken in a file the change did not touch — which is why the scheduled full sweep exists — so the integration suites are run instead, at ten minutes against fourteen hours.
|
@theredspoon Pushed — 46 commits, merged with v0.28.0, 0 behind main. Twenty-one of your twenty-five asks are closed and no product code is open. But the thing worth your time is not in your list. Un-skipping one test found a panicItem 24 asked me to un-skip
Three consequences, all observed rather than reasoned about:
Why nothing caught it: the unit test for that guard calls That is your closing observation, one level deeper than you put it. Fixed in Four claims of mine were wrong
Your listItem 8 — the target's USERAUTH answer has a flat 30s bound of its own, applied at all five call sites so a new credential type cannot arrive unbounded by being forgotten. New Item 15/18 — Item 19 — thirteen sites, all through Item 31 — Item 17 residual — Doc nits done. And you were right to narrow the role-validation claim: what shipped is a syntactic name-format rule shared by the save path and the connect path, not a check that the role exists. Item I-4/I-6 attribution: you are right, both landed in The mutation matrix, in CI and measured
With the honest cost attached, because it changes the recommendation. A full sweep is 14h20m on an M-series laptop. GitHub-hosted runners are 4 vCPU and slower, and the job limit is six hours, so the full sweep does not fit there at all. And the per-PR saving is smaller than it sounds: Six defects turned up in the instrument itself along the way. It would not start at all — its own duplicate check tripped on a duplicate that had been committed. Its documented invocation ran from nowhere. It paid the whole repository's collection cost for a single guard, in three separate places. It printed nothing for hours, so three runs were killed as hung. Two runs executed side by side rewriting the same files, because the lock was written rather than checked. And the mutated gateway leaked into the next guard's baseline, which voided 28 verdicts of a preceding sweep. 53 of 53 guards discriminate, measured in one run at Not done, deliberatelyUpstream logs client-controlled strings through Your process suggestionIt sat unanswered for six days and you were right to raise it again. It is now half-built, and not in the shape you drew. Rather than RED and GREEN subagents inside one process, the split is across two providers with hard role separation: I build and operate checks, an independent auditor specifies them and rules, and neither writes into the other's territory. Deterministic checks are run by orchestration, as you said — that is what the matrix is. It paid for itself in a way I would not have predicted. The auditor found the dirty-binary leak above — from a running sweep's output, not from reading the code. I had looked at the same two Everything green after the merge: 63 + 26 + 4 unit tests, 109 integration tests against a real sshd. |
…icate-auth # Conflicts: # Cargo.lock # warpgate-protocol-ssh/Cargo.toml
…icate-auth # Conflicts: # warpgate-protocol-ssh/src/server/service_output.rs
|
Verified the two CI/measurement items against The "in CI, measured" section isn't, by its own numbers. It opens describing 53 of 53 guards discriminate is cited to Ask: correct the write-up so it doesn't describe this as running in CI, repoint the citation to |
The artifact's headline numbers were `len(MUTATIONS)` and `len(DISCRIMINATES)` — the height of the guard table and the number of declarations in it — under the names `guards_total` and `guards_with_a_named_discriminator`. Both read as results. A four-guard `--changed` run and a fourteen-hour sweep emitted the same "53 / 53", so the file could not tell them apart, and `partial` was set from whether a `--named <substring>` filter had been passed, which made every `--changed` subset call itself complete. That is how a review asking to check "53 of 53 discriminate" against the artifact found an artifact from a four-guard run stating exactly that number. The counts now count this run: guards selected, guards measured, guards that discriminated, and `partial` derived from whether the run covered the table. `--shard INDEX/TOTAL` splits the guards round-robin so a sweep can be spread across machines. Round-robin rather than contiguous blocks because the integration guards rebuild the gateway and cost minutes each while the unit guards cost seconds, and the table groups them by subject — contiguous blocks would hand one shard every expensive guard.
`workflow_dispatch` is only offered for workflows on the repository's default branch, and this one is deliberately not there: it lives on a branch of its own so it stays out of PR warp-tech#2397. A push to that branch is the trigger left, and it suits a job that takes hours across eight runners — a sweep is something you ask for, not something that happens to you.
Two defects, both of which let a run state a result it had not established. Outcomes were inferred rather than read. Only the `FAILED` lines were parsed and every other named test was recorded as passed, so a skipped test, a test whose fixture raised, a deselected test and a test pytest never mentioned were all indistinguishable from a passing one — the confusion `failing_tests` already refuses for a whole suite, left open for a single test. With `-rA` each test states an outcome; anything that is not plainly passed or failed is now unclear, an unclear baseline records `no baseline` rather than proceeding to measure against a precondition never met, and an unclear guard-off run records `no verdict` rather than `does not discriminate`. The test's name was taken from the wrong end of the line. A summary line is `FAILED <nodeid> - <message>`, the message in these suites is a slice of Warpgate's own log, and that log carries Rust module paths — so splitting on the last `::` returned `logging:` and `config:` instead of a test name, and the test was not recognised as having failed. Two guards were reported as failing to discriminate on exactly this; both do discriminate, in CI and locally. Also: the gateway binary is fingerprinted across the guard-off build, because an A/B whose halves ran the same binary is not an A/B and reads as a coverage hole. Verified: 53 of 53 guards discriminate, every guard measured, shards accounting for the whole table — https://github.com/janisdombr/warpgate/actions/runs/32802468772
A skip is the one outcome pytest's short summary does not attribute to a test: the line is `SKIPPED [1] <file>:<line>: <reason>`, with no nodeid in it, so a skipped test cannot be matched to the name a guard declares. It was recorded as "never reported by pytest", which is true and unhelpful — it reads like the run lost the test rather than like the test was skipped on purpose. A name that goes unreported while skips were printed now says so and quotes the first one. The verdict is unchanged: a skipped test is still not a baseline and still not a discriminator.
|
@theredspoon thank you Confirmed on all three, and following the third down found the number itself was wrong. Taking them in order. The workflow never existed in the repository. You're right that So Citation repointed. The artifact — and this is where it gets worse. Going to fetch it found So I withdrew the number, fixed the counts to count the run, put the workflow on a branch of my fork where it could actually execute, and ran it. That first real run came back 51 of 53. Then two more defects surfaced, each hiding the next:
53 of 53, on a run you can open: https://github.com/janisdombr/warpgate/actions/runs/32802468772 — every guard measured, a summary job requiring the shards to account for the whole table before it will call the result a sweep. I should be plain that this invalidates my earlier defence of the number as well as the number. When you asked for the artifact I argued a The instrument. The tool is in this PR at
On the six hours — you were right, and so was the number that made me say it, but for a reason that turns out to weaken the argument. An unsharded "every guard" job could only ever be started, never finished, and the workflow now shards eight ways. But the 14h20m I quoted for the local sweep is not fourteen hours of work. Seven hours thirty-five minutes of a closed laptop, counted as run time. The real figure is at most 6h45m of work — against about 5.3 machine-hours across the eight CI shards. A ratio of 1.27, not the 24 I was implicitly claiming. So the sweep is roughly six or seven hours of single-machine work, which is close to the six-hour ceiling rather than comfortably over it; sharding is still the right shape, but "does not fit" was overstated and I should not have leaned on it. |
|
Confirmed all three fixes ( One number doesn't add up. You wrote the local sweep was "at most 6h45m... against about 5.3 machine-hours across the eight CI shards." Summing the final run's 8 shard durations directly from the job timestamps gives about 2.55 hours, not 5.3. The only way I got close to 5.3 was adding the final run to the immediately preceding failed attempt (2.55h + 2.68h ≈ 5.23h), which isn't "across the eight CI shards" of one run. Ask: show where 5.3 comes from, or correct it. |
|
@theredspoon You're right, and the way I got 5.3 is worse than the number. It was not a sum of anything. It was Measured properly, from api 2.55, and that breaks the argument I built on it. I told you an unsharded "every guard" job could only ever be started, never finished, against GitHub's six-hour cap. At 2.55 machine-hours a single job would take about two and a half hours and fit inside the cap with room to spare. Sharding buys wall-clock latency — 28.8 minutes instead of ~2.5 hours — which is worth having, but it is not the feasibility argument I gave you, and the workflow comment saying otherwise is wrong. I'll correct it. On the local side: 6h45m is an upper bound, not a measurement. It is 14h20m of reported wall clock minus 7h35m the laptop spent asleep ( The one number I'd stand behind is 2.55 machine-hours, because it comes from the job timestamps and you can pull the same JSON. |
Five commits: the v0.28.4 version bump, an RDP lossless-compression option, a role-assignment permission fix, and a workflow edit. Both generated OpenAPI schemas conflicted. They are not files to resolve by hand — whatever a three-way merge produces from generated JSON is not what the generator would emit, and nothing downstream would notice. Resolved by taking one side to clear the conflict and regenerating from the merged Rust types. That turned out to matter. The admin schema, resolved textually, was missing `RdpTargetCompression` — the type upstream's RDP commit adds. Regeneration restored it. The gateway schema needed no semantic change: regenerated, it carries upstream's content in full plus this branch's `Info.has_vault`, and differs from the committed file only in the `git describe` build stamp.
…ecordings
Three commits. Two overlap this branch only by file; one changes something this
branch depends on.
`56d131d5c` removes the `Mutex` from `session` so a hanging channel open no
longer holds the whole session. Every `session.lock()` it drops is upstream's
own; this branch adds none. Its bound and ours address the same class of hang at
different sites: ours wraps the `channel_open_direct_tcpip` that builds the
chain to the target, theirs the one serving a channel the client asked for.
`2a272c3a4` makes the three PTY sinks synchronous — `emit_pty_output`,
`emit_service_message` and `emit_pty_error` are no longer `async`. This branch
applies its control-character escaping inside those sinks, so the signatures are
taken from upstream and the escaping re-applied in the new bodies. Kept on this
side: `ConnectionError::Authentication` carrying its reason, `client_message()`
rather than `{error}` on the path to a connected user, and the whole-chain
`RCCommand::Connect` the per-hop attribution needs.
Also adds a guard. `test_a_hostile_option_name_cannot_write_to_the_terminal`
had no guard naming it, so nothing would have noticed if the escaping it relies
on were dropped by a merge like this one. It is `{name:?}` in `client/mod.rs` —
`Debug`, which renders an escape sequence inert. Two earlier attempts anchored
this guard on the session sinks instead and neither discriminated; the matrix
said so before any of it left the machine.
|
|
||
| let response = self | ||
| .http | ||
| .post(self.url(path)) |
CodeQL reports "cleartext transmission of sensitive information" on the request built in `VaultClient::url`, High severity, reaching it from the AppRole secret ID. The alert is a false positive as the code stood: `validate_address` refuses any address that is not HTTPS unless it is loopback, `VaultClient::new` is the only constructor, and it calls that check on its first line. There is no path to a request that has not been through it, and a property test pins the rule. The analyser cannot see that, and it is right not to trust it. The guarantee was a property of call order rather than of the code: safe only while `new` remains the sole constructor and keeps calling `validate_address` first, which is exactly the sort of invariant a later refactor drops without anything noticing. So `validate_address` now returns the parsed `url::Url` instead of `()`, the client stores it, and `url()` builds every request from that field rather than from `config.address`. The only way to obtain something this client will send a request to is to have come through the check. The scheme warning reads the parsed scheme too, rather than matching a string prefix. No behaviour changes: the same addresses are accepted and refused, and the same warning is emitted for a loopback Vault over plain HTTP. 30 tests in the crate pass, including the property test asserting every accepted address is HTTPS or loopback.
…ation Four commits, of which one matters here: `3c44340cd` adds a `target` to the ticket-request responses in the gateway API. That is why `check-schema-compatibility` failed on the previous head. The gate regenerates this branch's schema and compares it against **main's current** committed one, so it reports every API addition upstream has made that this branch has not yet merged as a removal on our side. Nothing was wrong with the schema we had; it was four commits stale. Both schemas conflicted again and were resolved the same way as before — take a side to clear the conflict, then regenerate from the merged Rust types, because a three-way merge of generated JSON produces something no generator would emit. Verified after regeneration: nothing upstream carries is missing from either file (admin gains 26 paths from this branch, gateway 2, and loses none).
CodeQL reports eleven high-severity cleartext-transmission alerts against this crate, and they are not false positives. Every login sends a credential — a projected service account token, an AppRole secret ID, a signed cloud identity — and `validate_address` permitted `http://` for loopback, so a path existed along which one of those crossed the wire in the clear. The exception was there so a development Vault needed no certificate. That is a poor trade in a change whose entire subject is not leaving secrets exposed. The exception is gone: an address is HTTPS or it is refused. The warning that existed to announce the insecure case goes with it, being unreachable. The tests followed the rule rather than the other way round. The Python stub serves TLS from a self-signed certificate and hands its path to `vault.ca_bundle`; the Rust stand-ins do the same through `rcgen` and one shared certificate. Cloud metadata keeps a second, plaintext listener, because a real metadata service answers over HTTP on a link-local address and testing it over TLS would have exercised a shape that does not exist. Two things surfaced on the way: `rustls` refuses a server certificate without `extendedKeyUsage=serverAuth`, and reports it as `EkuError` — which reads as a plain verification failure and names nothing. The stub's certificate now carries it. `alloc_port` returned `last_port += 1` without checking whether anything held that port, so a caller learned it was taken only when its own `bind` raised `Address already in use`, well inside a test that then failed for a reason unrelated to its subject. It cost about one failure per run of the hostile-target suite. It now probes the way the callers bind before returning. 53 unit tests in the affected crates and all 93 integration tests pass. The address guard is renamed and re-anchored to the new rule, and discriminates.
`test_an_untrusted_vault_certificate_is_refused` serves a certificate it generated itself and expects the handshake to be refused. Giving every config in this module the shared test `ca_bundle` changed what that test exercises: with a bundle the client verifies against a root store the test supplied, without one it uses the platform verifier. Those are different mechanisms and they disagree across platforms — the test passed on macOS and failed on Linux in CI. It now builds its config without a bundle, which is what it had before the stand-in servers gained TLS. The subject is the default trust decision, and the comment says so.
Warpgate authenticates to an SSH target with a short-lived OpenSSH user certificate signed on demand by HashiCorp Vault, instead of a private key it stores. The ephemeral keypair is generated per connection and never persisted, so a compromise of the Warpgate host yields nothing a target would accept.
Targets trust the CA through TrustedUserCAKeys and need no authorized_keys. The certificate's key ID carries the Warpgate username and session UUID, so the target's own sshd log attributes a proxied session to a person rather than to the gateway.
VaultAuth offers workload identity only — kubernetes, AppRole, AWS, Azure and GCP. Each reads its credential from a file or a metadata service, never from the config: a static Vault password would merely relocate the long-lived secret this feature exists to remove. Full compatibility with OpenBao is supported.
Verified end to end against real infrastructure — AWS STS, a GCE instance, an Azure VM and a k3d cluster.
tests/test_ssh_target_cert_auth.py runs against a stub issuer and needs neither Vault nor a cluster.
Discussion: #26
Special thanks to @theredspoon for the detailed test, OpenBao evaluation, and security recommendations.
Description
...
AI Usage
Choose the level of AI involvement for this PR.
This is not to block AI contributions but rather to speed up PR review (saves time on trying to deduce the logic behind AI hallucinations).