Repository navigation
fix(runtime): VendSecret over mTLS — the boot contract passes end to end - #202
Conversation
…nd to end
`EXPECT_VENDSECRET=1` passes on real KVM. All four assertions green, twice, with
no leaked TAPs. The security-critical part of the boot contract — a per-VM
client cert presented over mTLS, checked against the manager's allowlist — is
exercised on every boot instead of being skipped.
Three faults, each found only after the previous one was cleared.
1. tonic silently replaced the TLS config. The generated client's `connect()`
routes through `tonic::transport::Endpoint::new`, which for an https:// URI
does:
return me.tls_config(ClientTlsConfig::new().with_enabled_roots());
overwriting the configured TLS, and `with_enabled_roots` discards its own
receiver. With neither roots feature enabled the result is an EMPTY root
store, no client identity, and no domain — which is exactly what the guest
showed: UnknownIssuer (no roots), "client auth requested but no cert
available" (no identity), and verification against the URI's address (no
domain). Three symptoms, one cause. Connect via `endpoint.connect()` and
build the client from the channel; that path does not go through
`Endpoint::new`, so the configured TLS survives.
2. Declared secrets never reached the guest. The Firecracker backend never put
`spec.secrets` in the boot args, so the harness saw none declared and its
cache refused every request — a spec declaring a secret behaved exactly like
one declaring none. The manager now emits one `lantern.secret.<ENV>=<uri>`
per secret and the harness reassembles the JSON. One key each rather than a
JSON blob: kernel args are whitespace-separated and quotes are stripped from
values, so serialised JSON arrives mangled.
3. Nothing ever populated the secret cache. `refresh_loop` only renews entries
already cached and `get_or_vend` is driven by the workload asking over the
socket, so a VM with no workload never exercised the vend path at all —
precisely the path worth verifying. The harness now prefetches declared
secrets at boot, best-effort and spawned so a slow manager cannot delay
boot. This does hold a declared-but-unused secret in guest memory for the
VM's life; that is already true of any secret the workload touches once, and
declared secrets are by definition ones it may use.
Verified: 2 consecutive runs, 4/4 assertions each, 0 leaked TAPs. 153 harness +
346 manager tests pass, clippy and rustfmt clean on both.
Fault (1) is a tonic footgun worth knowing about repo-wide: any code doing
`SomeClient::connect(endpoint)` over https with a custom ClientTlsConfig is
silently getting default TLS instead.
🔷 Gemini cross-audit (agent:audit-gemini)An independent audit of the unified diff in 1. Blocking / Critical Security Issue🚨 Command-Line / Boot-Argument Injection via Unsanitized Whitespace
2. Correctness & Concurrency Issues
|
EXPECT_VENDSECRET=1passes on real KVM. All four assertions green, twice, with no leaked TAPs:The security-critical part of the boot contract — a per-VM client cert presented over mTLS and checked against the manager's allowlist — now runs on every boot instead of being skipped.
Three faults, each visible only once the previous cleared
1. tonic silently replaced the TLS config
The generated client's
connect()routes throughtonic::transport::Endpoint::new, which for anhttps://URI does:It overwrites the configured TLS — and
with_enabled_rootsdiscards its own receiver. With neither roots feature enabled that leaves an empty root store, no client identity, no domain.That is exactly what the guest showed, and it unified three symptoms into one cause:
UnknownIssuerFix: connect via
endpoint.connect()and build the client from the channel. That path doesn't go throughEndpoint::new, so the configured TLS survives.Worth knowing repo-wide: any code doing
SomeClient::connect(endpoint)over https with a customClientTlsConfigis silently getting default TLS.2. Declared secrets never reached the guest
The Firecracker backend never put
spec.secretsin the boot args, so the harness saw none declared and its cache refused every request — a spec declaring a secret behaved identically to one declaring none. The manager now emits onelantern.secret.<ENV>=<uri>per secret and the harness reassembles the JSON. One key each rather than a JSON blob, because kernel args are whitespace-separated and quotes are stripped from values.3. Nothing ever populated the secret cache
refresh_looponly renews entries already cached, andget_or_vendis driven by the workload asking over the socket — so a VM with no workload never exercised the vend path at all, which is precisely the path worth verifying. The harness now prefetches declared secrets at boot: best-effort, and spawned so a slow manager can't delay boot.Tradeoff, stated plainly: this holds a declared-but-unused secret in guest memory for the VM's life. That's already true of any secret the workload touches once, and declared secrets are by definition ones it may use.
Verification
2 consecutive runs, 4/4 assertions each, 0 leaked TAPs. 153 harness + 346 manager tests pass; clippy and rustfmt clean on both crates.
This closes the gap documented on #198.