--- id: w1-key-sources name: "Wave 1: vault-backed key sources (ADR-005 custody in code)" status: completed depends_on: [w1-manifest, w1-error-model] scope: moderate risk: medium impact: project level: implementation --- ## Description Land the ADR-005 custody model in code: the key-source seam both directions consume (client dial keys, server host keys): - **The source seam**: the adapter takes *key sources*, not static keys (the alkhttp ADR-014 pattern): a source trait the assembly implements over an injected alkvault handle, `EncryptedData` opaque to the crate (ADR-005 decision 5: the crate never interprets `key_version` or record internals). - **At-use lifecycle** (decision 2): `vault.decrypt(blob)` → **`Zeroizing` wrap at the adapter seam** (decision 3, the known-gap contract; the vault-side improvement is upstream, not here) → one-time `decode_secret_key(plain, None)` → the decoded `PrivateKey` lives for the dial's lifetime. The vault is needed only at dial time; locking mid-session keeps live sessions streaming. - **Host-key load path** (decision 6): decrypt-at-construction returning the `PrivateKey` set the server `Config::keys` will consume (the `Config` construction itself is wave 2; locked vault → the load fails → **no server is constructed**, POC-2 §5). - **Failure mapping**: every vault failure mode maps to its distinct `KeySourceError` variant (locked / absent / decryption / unknown version / decode); never collapsed, never a fallback dial. - **Plaintext never persists** (decision 7): no disk spill on this path, ever; the `Zeroizing` wrap bounds the plaintext from the adapter seam onward. Inner format is OpenSSH private-key PEM, unencrypted (decision 1); the source contract assumes it, and the decode call is the single conversion. ## Acceptance Criteria - [x] Source trait(s) take the vault handle injected; `EncryptedData` opaque (no `key_version` interpretation anywhere in `src/`) - [x] Decrypt → `Zeroizing` → one-time `decode_secret_key` chain in place; the plaintext never outlives the wrap - [x] Host-key load path returns the decoded key set; locked vault → `KeySourceError::Locked` (no server construction possible) - [x] Round-trip test: encrypt(PEM) → source → decoded ed25519 key usable by russh - [x] Failure-mode tests: locked / absent / tampered ciphertext / unknown version / undecodable plaintext each map to their distinct variant - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/decisions/005-at-rest-key-custody.md (decisions 1–7) - docs/architecture/auth.md §Key custody - docs/research/poc-2-vault-keys.md (§1 the 4-way table, §2 lifecycle + zeroization bounds, §3 failure matrix, §5 host keys end to end) - docs/research/auth-bridge.md §The gap (the source-shape reasoning) ## Notes > `src/key_source.rs` landed: two injected-handle seams and the > crate-provided vault-backed implementations over them. > > - **Traits** (`async_trait`, the `HostKeyPolicy` handle shape): > `KeySource::load() -> Result` (the > wave-4 dial-config carries `Arc`) and > `HostKeySource::load() -> Result, > KeySourceError>` (wave-2 serve-config; decrypt-at-construction, > a *set* because `Config::keys` takes multiple). An assembly may > implement either trait itself; the crate ships the ADR-005 > implementations and the custody machinery. > - **`VaultKeySource`**: decrypt-at-use over an injected clone of > the assembly's `VaultServiceHandle` + one injected > `Option` record (`None` → `Absent`, the POC-2 §1 > `KeySource { blob: Option }` shape). > **`VaultHostKeySource`**: same model over a record set (empty set > → `Absent`). Both are decrypt-at-use chains > (`decrypt_and_decode`, shared): `vault.decrypt(blob)` → > **`Zeroizing` at the seam** → one-time > `decode_secret_key(plain, None)`. Blobs are never interpreted in > `src/`: no `key_version` reads, no record internals. > - **Failure mapping** (`map_vault_error`): `VaultLocked` → > `Locked`; `InvalidPath` → `UnknownKeyVersion`; `Encryption` → > `Decryption`. The vault's decrypt-path-unreachable variants > (`Derivation`/`Mnemonic`/`AlreadyUnlocked`/`UnsupportedKeyType`) > land in the `Decryption` refuse-at-crypto class with honest > message text; the five-variant matrix stays closed, nothing > collapses a real custody condition into success. > - **Tests** (12, all in-module with the real in-memory > alkvault handle: `VaultServiceHandle::new()` is the in-memory > construction, so no local vault double was needed): > round-trip (encrypt PEM → source → fingerprint-identical decodable > ed25519 key that signs), dyn-handle load, host-key set order, > load-works-after-lock (POC-2 §2 lifecycle), locked / absent / > tampered-ciphertext / wrong-mnemonic → `Decryption` (distinct > from tampered only by message, both GCM-open) / version 0+1 → > `UnknownKeyVersion` / non-key plaintext → `Decode`, locked > host-bootstrap refusal, empty host set, v2→v3 rotation-preserves > -identity through the source. Key fixtures: three committed > throwaway ed25519 PEMs embedded as test constants > (w1-test-doubles' committed-static-PEM posture; generated at > test-fixture time with ssh-keygen, never deployed/registered). > > Interpretation pinned for test-doubles: alkvault 0.1 *does* ship > the in-memory construction (`VaultServiceHandle`), so the double > wraps the real handle; no local decrypt/decrypt double. ## Summary > `src/key_source.rs` (lib re-exports `KeySource`, `HostKeySource`, > `VaultKeySource`, `VaultHostKeySource`), and ADR-005 is in code: > the > injected-handle source seams both directions consume, the crate's > vault-backed implementations carrying decrypt-at-use (vault.decrypt > → `Zeroizing` → one-time `decode_secret_key`), opaque > `EncryptedData`, and the full five-variant failure mapping. > 12 tests cover the round-trip (usable-by-russh via signing), the > full failure matrix, the host-key decrypt-at-construction refusal > (locked → no server constructed), lock-mid-session lifecycle, and > rotation identity preservation. Verified: cargo test (23 + 4 > parity), cargo test --all-features, clippy -D warnings (default + > all-features), fmt --check, taskgraph validate.