Add SSH certificate authentication for targets, issued by Vault - #2397
janisdombr wants to merge 119 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. |
`disconnect_server` queued the connection-level `Disconnect` behind the channel closes, so it left for the client in the same breath as whatever the session had just said. A client handed both in one read acts on the disconnect and exits without printing what it already holds, and the session's last words are lost — which is the one thing those messages exist to prevent. A session closed for inactivity says so and disconnects in the next statement, so that path lost its notice every time: the client saw the target's output and then nothing. The disconnect now goes out from the task that already waits out the flush grace before closing the socket, and only to a client that is reading; one that is not still gets cut immediately. The channel closes stay in the queue, so per-channel ordering is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Rebased on The head also carries #2584 as a single commit, because without it five of this PR's certificate-refusal tests go red: they assert that Warpgate told the client why the connection failed, and the connection-level Local run on this head: 159 integration tests pass, |
# Conflicts: # warpgate-admin/src/api/users.rs # warpgate-core/src/approvals/tests.rs # warpgate-web/src/admin/lib/openapi-schema.json
The guard exists to catch an unbounded read, and an unbounded read grows anonymous memory. It was measuring VmRSS, which also counts file-backed pages of the executable -- 561 MiB under coverage instrumentation -- whose residency the kernel decides. Two CI runs read a 271 and a 275 MiB rise against modes that send 16 KiB and 8 MiB; anonymous memory sits at 17 MiB. Read RssAnon from /proc where the kernel reports it, fall back to rss elsewhere. The threshold is unchanged.
Both sides of the merge with main moved rustls to 0.23.45, but one package's dependency list still named 0.23.43 after the textual merge, and `cargo metadata --locked` refused the tree. Cargo rewrote the edge on its next run; this commits that rewrite so the Lockfile check passes.
|
Rebased on 160 integration tests pass locally on this head; no mutation-matrix guard is anchored in anything the eight upstream commits touched. |
# Conflicts: # warpgate-web/src/admin/lib/openapi-schema.json
# Conflicts: # warpgate-admin/src/api/targets.rs # warpgate-admin/src/api/users.rs
# Conflicts: # warpgate-protocol-ssh/src/client/mod.rs
A Vault error body arrives verbatim — bounded and UTF-8-repaired, but with its
control characters intact — and a newline in it forges a whole record in the
default text format that a reader cannot tell from one Warpgate wrote. The
connect path already rendered this value with `{:?}` and said why; the session's
sink for the same value — it arrives there as `RCEvent::ConnectionError`, sent
from that very line — used `{}`, so every forgery the first sink refused went
through the second untouched.
Escaped at the type and at the sink, because one sink is not the property. The
`Api` variant renders its body with `{body:?}`, which is what makes web-ssh's
`%e` and every sink added after it safe without editing any of them; the field
stays raw, so `client_message` still classifies on its text and nothing else
reads the rendered form. The session's sink keeps `{:?}` for the rest of the
enum, whose transparent variants hand their inner text to the log as written.
The call moves into `log_target_connection_failure`, named the way web-ssh's
`shown_to_the_browser` is, so a test has somewhere to stand that a call inside
the event loop does not. `#[deny(dead_code)]` ties the two back together: `mod
tests` is `#[cfg(test)]`, so a call site that goes back to logging inline fails
an ordinary build instead of orphaning the helper while its test stays green.
The regression test's fixture is a transparent variant rather than the Vault one
it was written against — Vault's body is escaped by its own Display now, so that
fixture would pass with the sink reverted and prove nothing about it. The Vault
case stays as a second test of the path end to end: it holds while either layer
does, which is what having both is for.
web-ssh's two `error=%e` sinks and `RCEvent::Error` are deliberately left alone.
warp-tech#2548 changes all three, and after this the Vault body — the only hostile text
this branch introduces — is escaped before it reaches them anyway; editing them
here would only manufacture a conflict.
Guards 57 and 58 in the mutation matrix anchor on the sink and on the rendering.
The signing answer is the body up to MAX_RESPONSE_BODY plus one crossing chunk, and the stub task is joined so a write failure before the crossing is reported as the stub's, not read as the client's. A body advertised past the limit and cut short below it is the control: it must be a transport error, and the strict size assertion must reject it.
The call generates the server-default RSA-4096 CA, which takes seconds even on an idle runner: over 100 fresh containers each, Vault 1.20.4 took a median 1.24 s, p95 4.01 s, max 5.47 s, and OpenBao 2.5.0 a median 0.59 s, max 2.53 s; none reached 10 s. The timeout this fixes came inside a 26-minute full suite rather than on an idle runner. 60 s is more than tenfold the idle maximum. Every other call keeps 10 s so a server that has stopped answering still fails fast, and the CA type stays the server default so the contract still exercises it.
The warp-tech#2185 merge put secret resolution inside the deadline this branch gives a target to authenticate. The password arm resolved its reference there, and the public-key and IAM-role arms reach the same resolver through `load_client_keys`. That deadline is thirty seconds; a backend's cold path is not. It logs in on first use, reads, and on a `403` logs in again and reads again, each request bounded at 15s by warp-tech#2185 — so a backend within every one of its own bounds failed the login with `AuthenticationTimeout`, whose message names the target or the issuer. Neither had been asked anything. Before the merge warp-tech#2185 had no aggregate limit here, and this branch's limit never covered backend work: the composition introduced it. Preparation now runs first, in `prepare_auth`, and the deadline starts once it returns. No second, shorter bound is put around it: warp-tech#2185's per-request bounds already make it finite, and any aggregate that did not cover cold initialisation plus the one permitted reauthentication would reintroduce the same failure under a different name. The certificate arm prepares nothing — its issuer call stays inside the budget that scales with `vault.timeout`. A backend failure gets its own variant. It used to arrive as `ConnectionError::Warpgate` and render as "Internal connection error", so warp-tech#2185's own user-facing reason was never reached; now the client is told the credential could not be obtained from the secret backend, with that reason — one of a fixed set, naming no backend, path or Vault text. The detail goes to the log escaped, at the type, because the admin host-key check logs with `{:#}`. A database error or an undecodable key stays in the catch-all: it is not the backend's. `prepare_then_authenticate` is the seam a test can stand on. The test drives a fake backend through four steps, each half the budget, and passes; with preparation moved back inside the deadline it fails with the target-blaming timeout. Scaled-down durations rather than tokio's paused clock, for the reason `bounded_userauth_within` gives. Guard 59 in the mutation matrix anchors on the order.
Its doc said it ran on a paused clock and could not flake; it runs on real, scaled-down milliseconds, because the workspace's tokio has no test-util.
The endless-body test separated "stopped at the cap" from "read the stream to the end, then truncated" with a wall-clock bound: both return the same body, so elapsed time was the only oracle. Almost none of that time is the reader. On this machine the first TLS handshake of a test binary run from the cargo target directory takes 8 to 20 seconds; the same binary copied elsewhere runs the whole test in 30 to 50 ms. Once the handshake alone crossed 20 seconds under load, the test failed with the reader unchanged. The stub now streams unpaced and reports how many bytes it wrote before the client closed. After the reader stops, what can still get through is bounded by buffers, not by scheduling. A reader that drains the stream reaches the 16 MiB limit, where the stub stops writing but keeps the body open, so the reader still cannot finish and the test fails at once with the count rather than waiting on the timeout. The only timing left is a watchdog against a hang.
Every process wrote its freshly generated certificate to one fixed path under the system temp directory. Two runs at once, whether from another worktree or a mutation run, replaced each other's CA bundle between writing it and loading it. Their handshakes then failed with "certificate is not trusted", which looked like a problem in the client under test. The bundle has to stay a file, because `ca_bundle` is a path in the config the client is built from. A directory per process removes the sharing, at the cost of one small file left behind per run.
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).