From 4d529dbacfb4e47011ebc0fa52ad896844f98a2d Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 28 Oct 2026 06:45:56 +0000 Subject: [PATCH] feat(key-source): land the vault-backed key sources (ADR-005 custody in code) - Two injected-handle seams: KeySource (dial-config, decrypt-at-use -> one PrivateKey) and HostKeySource (serve-config, decrypt-at-construction -> the Config::keys set); async_trait object-safe, the HostKeyPolicy handle shape - Crate-provided ADR-005 implementations: VaultKeySource (injected vault handle + Option, None -> Absent) and VaultHostKeySource (record set, empty -> Absent) over the shared decrypt_and_decode chain: vault.decrypt -> Zeroizing seam wrap -> one-time decode_secret_key(plain, None) - EncryptedData opaque in src/ (no key_version interpretation); vault error taxonomy reshaped into the five-variant KeySourceError matrix, never collapsed; plaintext never persists - 12 in-module tests on the real in-memory alkvault handle: round-trip (fingerprint-identical ed25519 key that signs), full failure matrix, locked host-bootstrap refusal, lock-mid-session lifecycle, v2->v3 rotation identity; three committed throwaway PEM fixtures Verified: cargo test (23 + 4 parity), cargo test --all-features, clippy -D warnings (default + all-features), fmt --check, taskgraph validate --- src/key_source.rs | 513 +++++++++++++++++++++++++ src/lib.rs | 2 + tasks/implementation/w1-key-sources.md | 74 +++- 3 files changed, 580 insertions(+), 9 deletions(-) create mode 100644 src/key_source.rs diff --git a/src/key_source.rs b/src/key_source.rs new file mode 100644 index 0000000..5f33cfb --- /dev/null +++ b/src/key_source.rs @@ -0,0 +1,513 @@ +//! The vault-backed key sources — ADR-005's custody model in code, +//! both directions (client dial keys, server host keys). +//! +//! The adapter takes *key sources*, not static keys (the alkhttp +//! ADR-014 pattern): the consumer surfaces (wave-4 dial-config, wave-2 +//! serve-config) carry injected handle traits, and the assembly +//! implements them over an injected alkvault handle. The crate ships +//! the vault-backed implementations for the ADR-005 shape +//! ([`VaultKeySource`] for one dial key, [`VaultHostKeySource`] for +//! the host-key set) along with the decrypt-at-use chain they run — +//! the custody machinery is the crate's, the at-rest storage is the +//! assembly's. +//! +//! Crate rules, per ADR-005: +//! +//! - **`EncryptedData` is opaque** (decision 5): blobs flow through +//! this module to `VaultServiceHandle::decrypt` untouched — no +//! `key_version` interpretation, no record-internals access +//! anywhere in the crate; version semantics belong to the vault. +//! - **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`](russh::keys::decode_secret_key) → the +//! decoded `PrivateKey` lives for the dial's lifetime. The vault is +//! needed only at use time: locking the vault mid-session keeps +//! live sessions streaming while new loads fail (POC-2 §2). +//! - **Inner format is OpenSSH private-key PEM, unencrypted** +//! (decision 1) — the source contract assumes it; the decode call +//! is the single conversion. A plaintext that is not that shape is +//! a [`KeySourceError::Decode`] refusal, not a fallback. +//! - **Failure modes are fail-closed with distinct variants** +//! (decision 4): locked / absent / decryption / unknown version / +//! decode map to [`KeySourceError`]'s five variants; never +//! collapsed, never a fallback dial. The vault's error taxonomy is +//! reshaped, never re-exported. +//! - **Plaintext never persists** (decision 7): no disk spill on this +//! path, ever. +//! - Vault lifecycle is the assembly's: this module never unlocks +//! the vault and never retries — a locked vault stays a refusal. + +use alkvault::{EncryptedData, VaultServiceError, VaultServiceHandle}; +use async_trait::async_trait; +use russh::keys::{PrivateKey, decode_secret_key}; +use zeroize::Zeroizing; + +use crate::error::KeySourceError; + +/// The client dial-key source seam — the handle the wave-4 dial-config +/// carries (never a static key, never env material). +/// +/// Produces the decoded [`PrivateKey`] for one dial, decrypt-at-use: +/// the vault participates only while the load runs, and the decoded +/// key outlives any vault state change (locking mid-session keeps +/// established sessions streaming; POC-2 §2). Implementations must be +/// fail-closed: a load that cannot produce key material returns the +/// matching [`KeySourceError`] variant — every variant is a +/// no-dial outcome, and there is no fallback key shape in the crate. +/// +/// The handle shape is the family's injected-handle pattern +/// (`Arc`, the alkcall `IdentityProvider` precedent): +/// object-safe via [`async_trait`], shared by reference — the vault +/// handle rides the implementation (the assembly clones its +/// [`VaultServiceHandle`] into its source). The crate's ADR-005 +/// implementation is [`VaultKeySource`]; an assembly may implement the +/// trait itself for other at-rest shapes, under the same inner-format +/// contract (OpenSSH-PEM, unencrypted). +#[async_trait] +pub trait KeySource: Send + Sync + 'static { + /// Loads the dial key — decryption plus the one-time + /// `decode_secret_key` (ADR-005 decision 2). + /// + /// This is the only surface where the dial direction touches + /// secret material: the plaintext exists solely inside this + /// method and is dropped (zeroized from the adapter seam onward) + /// before the decoded key is returned. + async fn load(&self) -> Result; +} + +/// The server host-key source seam — the handle the wave-2 +/// serve-config carries (ADR-005 decision 6). +/// +/// Produces the decoded host-key set for server construction — +/// decrypt-**at-construction**: the wave-2 `Config` builder calls +/// [`HostKeySource::load`] when it assembles the server, and a failure +/// means no server is constructed (POC-2 §5; locked vault at +/// bootstrap → [`KeySourceError::Locked`] → fail-closed, never a +/// half-configured server). The crate is policy-blind on host *pin*) +/// trust — that is the [`crate::HostKeyPolicy`] seam's business — this +/// seam is only the *held* material (the client's host pin is public +/// config, never vault material). +/// +/// The set, not a single key: `Config::keys` accepts multiple +/// host keys and fleets rotate them. Implementations must be +/// fail-closed in the same shape as [`KeySource`] — see the trait's +/// docs. The crate's ADR-005 implementation is [`VaultHostKeySource`]. +#[async_trait] +pub trait HostKeySource: Send + Sync + 'static { + /// Loads the host-key set — each blob through the same + /// decrypt-at-use chain as [`KeySource::load`]. Order follows the + /// source's registration order. + async fn load(&self) -> Result, KeySourceError>; +} + +/// The ADR-005 dial-key source — decrypt-at-use over an injected +/// alkvault handle, backed by one at-rest `EncryptedData` record. +/// +/// The record is injected at construction, from the assembly's +/// registry (`None` = the assembly holds no record at this key's +/// location → [`KeySourceError::Absent`], POC-2 §3). The vault handle +/// is a clone of the assembly's — this source never unlocks, locks, or +/// retries the vault's lifecycle state; every [`VaultKeySource::load`] +/// is decrypt-at-usage and a locked vault is a clean +/// [`KeySourceError::Locked`] refusal. +#[derive(Clone)] +pub struct VaultKeySource { + vault: VaultServiceHandle, + blob: Option, +} + +impl VaultKeySource { + /// Builds a dial-key source over an injected vault handle and the + /// injected at-rest record (`None` → absent). + pub fn new(vault: VaultServiceHandle, blob: Option) -> Self { + Self { vault, blob } + } +} + +#[async_trait] +impl KeySource for VaultKeySource { + async fn load(&self) -> Result { + match &self.blob { + Some(blob) => decrypt_and_decode(&self.vault, blob), + None => Err(KeySourceError::Absent), + } + } +} + +/// The ADR-005 host-key source — decrypt-at-construction over an +/// injected alkvault handle, backed by the injected at-rest record set. +/// +/// Each element of the set flows through the same decrypt-and-decode +/// chain as [`VaultKeySource`] (one model, both directions — +/// POC-2 §5). An empty set is [`KeySourceError::Absent`] (a host key +/// registration gap), which the wave-2 config builder treats exactly +/// like every other load failure: no server is constructed. +#[derive(Clone)] +pub struct VaultHostKeySource { + vault: VaultServiceHandle, + blobs: Vec, +} + +impl VaultHostKeySource { + /// Builds a host-key source over an injected vault handle and the + /// injected at-rest record set. + pub fn new(vault: VaultServiceHandle, blobs: Vec) -> Self { + Self { vault, blobs } + } +} + +#[async_trait] +impl HostKeySource for VaultHostKeySource { + async fn load(&self) -> Result, KeySourceError> { + if self.blobs.is_empty() { + return Err(KeySourceError::Absent); + } + self.blobs + .iter() + .map(|blob| decrypt_and_decode(&self.vault, blob)) + .collect() + } +} + +/// The ADR-005 at-use chain (decision 2): one vault decrypt, the +/// `Zeroizing` seam wrap, one `decode_secret_key`. +/// +/// The plaintext `String` leaves the vault as +/// [`KeySourceError`]-mapped failure or enters the wrap immediately — +/// its only unfenced window is the vault's own `String` return, the +/// known-gap contract of ADR-005 decision 3 (the upstream ask is +/// alkvault's; OQ-07). The decode is the single inner-format +/// conversion (decision 1); a plaintext that is not OpenSSH-PEM key +/// material is a [`KeySourceError::Decode`] refusal. +fn decrypt_and_decode( + vault: &VaultServiceHandle, + blob: &EncryptedData, +) -> Result { + let plaintext = Zeroizing::new(vault.decrypt(blob).map_err(map_vault_error)?); + decode_secret_key(&plaintext, None).map_err(|e| KeySourceError::Decode(e.to_string())) +} + +/// The vault's error taxonomy reshaped into ADR-005 decision 4's +/// five-variant matrix — never re-exported, never collapsed into one. +/// +/// `VaultLocked` → [`KeySourceError::Locked`]; `InvalidPath` (the +/// vault's refuse-before-crypto refusal of an unresolvable derivation +/// path — reserved/legacy v1, v0) → +/// [`KeySourceError::UnknownKeyVersion`]; `Encryption` (the GCM-open +/// class: wrong mnemonic, tampered ciphertext) is honest refuted +/// decryption → [`KeySourceError::Decryption`]. The remaining vault +/// variants cannot arise on the decrypt path (decryption never +/// touches mnemonic parsing, key derivation outside version-path +/// resolution, unlock state transitions, or key-type registration; +/// POC-2 §3) — if one were ever observed it would be a vault-side +/// refuse-to-decrypt condition, so it lands in the `Decryption` class +/// with the vault's message intact rather than silently succeeding. +fn map_vault_error(error: VaultServiceError) -> KeySourceError { + match error { + VaultServiceError::VaultLocked => KeySourceError::Locked, + VaultServiceError::InvalidPath(msg) => KeySourceError::UnknownKeyVersion(msg), + VaultServiceError::Encryption(msg) => KeySourceError::Decryption(msg), + VaultServiceError::Derivation(msg) => { + KeySourceError::Decryption(format!("derivation error: {msg}")) + } + VaultServiceError::Mnemonic(msg) => { + KeySourceError::Decryption(format!("mnemonic error: {msg}")) + } + VaultServiceError::AlreadyUnlocked => KeySourceError::Decryption( + "vault reported already-unlocked on a decrypt attempt".into(), + ), + VaultServiceError::UnsupportedKeyType => { + KeySourceError::Decryption("vault reported unsupported key type".into()) + } + } +} + +#[cfg(test)] +mod tests { + use alkvault::CURRENT_KEY_VERSION; + use russh::keys::Algorithm; + use russh::keys::signature::Signer; + use std::sync::Arc; + + use super::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource}; + use crate::error::KeySourceError; + use crate::fingerprint::fingerprint; + + /// Throwaway test fixtures generated at vector time with + /// `ssh-keygen -t ed25519` (OpenSSH 9.6p1); recorded here as + /// committed test constants — throwaway secrets, never deployed, + /// never registered anywhere real (the w1-test-doubles posture). + /// `ALPHA` is the client-dial fixture; `BRAVO`/`CHARLIE` are two + /// independent host-key fixtures. + const ALPHA_PEM: &str = concat!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n", + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n", + "QyNTUxOQAAACA7vQRGU0CqQMrqRUJef/2aZK/bR2mxnzBWWZHMUkq8/wAAAJgexTRBHsU0\n", + "QQAAAAtzc2gtZWQyNTUxOQAAACA7vQRGU0CqQMrqRUJef/2aZK/bR2mxnzBWWZHMUkq8/w\n", + "AAAEAjImC/Ay2J2FejKJYNoRllIF9+ed5Qmdd7erLCisK03Du9BEZTQKpAyupFQl5//Zpk\n", + "r9tHabGfMFZZkcxSSrz/AAAAEmFsa3NzaC13MS1rcy1hbHBoYQECAw==\n", + "-----END OPENSSH PRIVATE KEY-----\n", + ); + const BRAVO_PEM: &str = concat!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n", + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n", + "QyNTUxOQAAACDqobhDdf1GvNXSd8tK6HHp9tIlhWooOWSp+MS8jRTlTAAAAJjsjCqp7Iwq\n", + "qQAAAAtzc2gtZWQyNTUxOQAAACDqobhDdf1GvNXSd8tK6HHp9tIlhWooOWSp+MS8jRTlTA\n", + "AAAEDe5J9K933yhWNUQmQ6VdT1zBN9eu3/39IoR0Q+4hXpheqhuEN1/Ua81dJ3y0rocen2\n", + "0iWFaig5ZKn4xLyNFOVMAAAAEmFsa3NzaC13MS1rcy1icmF2bwECAw==\n", + "-----END OPENSSH PRIVATE KEY-----\n", + ); + const CHARLIE_PEM: &str = concat!( + "-----BEGIN OPENSSH PRIVATE KEY-----\n", + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n", + "QyNTUxOQAAACAnkAMvpNU6zs25fONkvgqt5E0V8eR8ySnFzv5OOdU2OQAAAJi/2ytvv9sr\n", + "bwAAAAtzc2gtZWQyNTUxOQAAACAnkAMvpNU6zs25fONkvgqt5E0V8eR8ySnFzv5OOdU2OQ\n", + "AAAEBqCsZ6AAe7S+AedrAYPjMBIcW10vHlaEWdIGH7jJQefCeQAy+k1TrOzbl842S+Cq3k\n", + "TRXx5HzJKcXO/k451TY5AAAAFGFsa3NzaC13MS1rcy1jaGFybGllAQ==\n", + "-----END OPENSSH PRIVATE KEY-----\n", + ); + + fn unlocked_vault() -> alkvault::VaultServiceHandle { + let vault = alkvault::VaultServiceHandle::new(); + vault.unlock_new(24).expect("in-memory vault unlocks"); + vault + } + + fn encrypt(vault: &alkvault::VaultServiceHandle, pem: &str) -> alkvault::EncryptedData { + vault + .encrypt(pem, CURRENT_KEY_VERSION) + .expect("vault encrypts the test PEM") + } + + fn direct_decode(pem: &str) -> russh::keys::PrivateKey { + russh::keys::decode_secret_key(pem, None).expect("test PEM decodes") + } + + fn assert_same_key(decoded: &russh::keys::PrivateKey, loaded: &russh::keys::PrivateKey) { + assert_eq!(decoded.algorithm(), Algorithm::Ed25519); + assert_eq!( + fingerprint(decoded.public_key()), + fingerprint(loaded.public_key()) + ); + } + + fn assert_usable_by_russh(key: &russh::keys::PrivateKey) { + let _ = key.try_sign(b"alkssh w1 key-source usability probe"); + assert!( + key.try_sign(b"alkssh w1 key-source usability probe") + .is_ok(), + "decoded key signs (usable by russh)" + ); + } + + #[tokio::test] + async fn round_trip_encrypt_then_source_yields_decoded_ed25519() { + let vault = unlocked_vault(); + let blob = encrypt(&vault, ALPHA_PEM); + + let source = VaultKeySource::new(vault.clone(), Some(blob)); + let loaded = source.load().await.expect("loads through the vault"); + + assert_same_key(&direct_decode(ALPHA_PEM), &loaded); + assert_usable_by_russh(&loaded); + } + + #[tokio::test] + async fn dyn_key_source_handle_honors_the_load() { + let vault = unlocked_vault(); + let blob = encrypt(&vault, ALPHA_PEM); + + let source: Arc = Arc::new(VaultKeySource::new(vault, Some(blob))); + let loaded = source.load().await.expect("loads through the dyn handle"); + + assert_same_key(&direct_decode(ALPHA_PEM), &loaded); + } + + #[tokio::test] + async fn host_key_set_loads_in_registration_order() { + let vault = unlocked_vault(); + let blobs = vec![encrypt(&vault, BRAVO_PEM), encrypt(&vault, CHARLIE_PEM)]; + + let source: Arc = Arc::new(VaultHostKeySource::new(vault, blobs)); + let loaded = source.load().await.expect("host keys load"); + + assert_eq!(loaded.len(), 2); + assert_same_key(&direct_decode(BRAVO_PEM), &loaded[0]); + assert_same_key(&direct_decode(CHARLIE_PEM), &loaded[1]); + } + + #[tokio::test] + async fn source_load_keeps_working_after_vault_lock() { + let vault = unlocked_vault(); + let blob = encrypt(&vault, ALPHA_PEM); + let source = VaultKeySource::new(vault.clone(), Some(blob)); + let loaded = source.load().await.expect("loads unlocked"); + + vault.lock(); + assert_same_key(&direct_decode(ALPHA_PEM), &loaded); + + source + .load() + .await + .expect_err("re-loads refuse once locked"); + } + + #[tokio::test] + async fn locked_vault_maps_to_locked() { + let blob = encrypt(&unlocked_vault(), ALPHA_PEM); + let vault = alkvault::VaultServiceHandle::new(); + + let err = VaultKeySource::new(vault, Some(blob)) + .load() + .await + .expect_err("locked vault is a refusal"); + assert!( + matches!(err, KeySourceError::Locked), + "expected Locked, got {err:?}" + ); + } + + #[tokio::test] + async fn absent_blob_maps_to_absent() { + let vault = unlocked_vault(); + + let err = VaultKeySource::new(vault, None) + .load() + .await + .expect_err("no record is a refusal"); + assert!( + matches!(err, KeySourceError::Absent), + "expected Absent, got {err:?}" + ); + } + + #[tokio::test] + async fn tampered_ciphertext_maps_to_decryption() { + let vault = unlocked_vault(); + let mut blob = encrypt(&vault, ALPHA_PEM); + let tampered = flip_byte_at(&blob.data, 4); + blob.data = tampered; + + let err = VaultKeySource::new(vault, Some(blob)) + .load() + .await + .expect_err("tampered ciphertext is a refusal"); + assert!( + matches!(err, KeySourceError::Decryption(_)), + "expected Decryption, got {err:?}" + ); + } + + #[tokio::test] + async fn wrong_mnemonic_vault_maps_to_decryption() { + let holder = unlocked_vault(); + let blob = encrypt(&holder, ALPHA_PEM); + let stranger = unlocked_vault(); + + let err = VaultKeySource::new(stranger, Some(blob)) + .load() + .await + .expect_err("an unrelated vault cannot open the record"); + assert!( + matches!(err, KeySourceError::Decryption(_)), + "expected Decryption, got {err:?}" + ); + } + + #[tokio::test] + async fn unknown_version_maps_to_unknown_key_version() { + let vault = unlocked_vault(); + let mut blob = encrypt(&vault, ALPHA_PEM); + + blob.key_version = 0; + let err = VaultKeySource::new(vault.clone(), Some(blob.clone())) + .load() + .await + .expect_err("version 0 is refused before crypto"); + assert!( + matches!(err, KeySourceError::UnknownKeyVersion(_)), + "expected UnknownKeyVersion, got {err:?}" + ); + + blob.key_version = 1; + let err = VaultKeySource::new(vault, Some(blob)) + .load() + .await + .expect_err("version 1 is refused before crypto"); + assert!( + matches!(err, KeySourceError::UnknownKeyVersion(_)), + "expected UnknownKeyVersion, got {err:?}" + ); + } + + #[tokio::test] + async fn undecodable_plaintext_maps_to_decode() { + let vault = unlocked_vault(); + let blob = encrypt(&vault, "this is not a key"); + + let err = VaultKeySource::new(vault, Some(blob)) + .load() + .await + .expect_err("decrypted non-key plaintext is a refusal"); + assert!( + matches!(err, KeySourceError::Decode(_)), + "expected Decode, got {err:?}" + ); + } + + #[tokio::test] + async fn locked_vault_at_host_key_bootstrap_is_a_refusal() { + let blobs = vec![encrypt(&unlocked_vault(), BRAVO_PEM)]; + let vault = alkvault::VaultServiceHandle::new(); + + let err = VaultHostKeySource::new(vault, blobs) + .load() + .await + .expect_err("locked vault at bootstrap is a refusal"); + assert!( + matches!(err, KeySourceError::Locked), + "expected Locked, got {err:?}" + ); + } + + #[tokio::test] + async fn empty_host_key_set_is_absent() { + let vault = unlocked_vault(); + + let err = VaultHostKeySource::new(vault, Vec::new()) + .load() + .await + .expect_err("no host-key registration is a refusal"); + assert!( + matches!(err, KeySourceError::Absent), + "expected Absent, got {err:?}" + ); + } + + #[tokio::test] + async fn rotation_preserves_key_identity_through_the_source() { + let vault = unlocked_vault(); + let blob = encrypt(&vault, ALPHA_PEM); + + let rotated = vault.rotate(&blob, 3).expect("rotates to v3"); + assert_ne!(blob.key_version, rotated.key_version); + + let loaded = VaultKeySource::new(vault, Some(rotated)) + .load() + .await + .expect("the rotated record still loads"); + assert_same_key(&direct_decode(ALPHA_PEM), &loaded); + } + + fn flip_byte_at(text: &str, index: usize) -> String { + let mut bytes = text.as_bytes().to_vec(); + if bytes[index] == b'A' { + bytes[index] = b'B'; + } else { + bytes[index] = b'A'; + } + String::from_utf8(bytes).expect("base64 mutation stays text") + } +} diff --git a/src/lib.rs b/src/lib.rs index 6f82379..67a9649 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,9 @@ mod error; mod fingerprint; mod host_key; +mod key_source; pub use error::KeySourceError; pub use fingerprint::fingerprint; pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll}; +pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource}; diff --git a/tasks/implementation/w1-key-sources.md b/tasks/implementation/w1-key-sources.md index faca008..032b7a7 100644 --- a/tasks/implementation/w1-key-sources.md +++ b/tasks/implementation/w1-key-sources.md @@ -1,7 +1,7 @@ --- id: w1-key-sources name: Wave 1 — vault-backed key sources (ADR-005 custody in code) -status: pending +status: completed depends_on: [w1-manifest, w1-error-model] scope: moderate risk: medium @@ -42,18 +42,18 @@ conversion. ## Acceptance Criteria -- [ ] Source trait(s) take the vault handle injected; `EncryptedData` +- [x] Source trait(s) take the vault handle injected; `EncryptedData` opaque (no `key_version` interpretation anywhere in `src/`) -- [ ] Decrypt → `Zeroizing` → one-time `decode_secret_key` +- [x] Decrypt → `Zeroizing` → one-time `decode_secret_key` chain in place; the plaintext never outlives the wrap -- [ ] Host-key load path returns the decoded key set; locked vault → +- [x] Host-key load path returns the decoded key set; locked vault → `KeySourceError::Locked` (no server construction possible) -- [ ] Round-trip test: encrypt(PEM) → source → decoded ed25519 key +- [x] Round-trip test: encrypt(PEM) → source → decoded ed25519 key usable by russh -- [ ] Failure-mode tests: locked / absent / tampered ciphertext / +- [x] Failure-mode tests: locked / absent / tampered ciphertext / unknown version / undecodable plaintext each map to their distinct variant -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -67,8 +67,64 @@ conversion. ## Notes -> To be filled by implementation agent +> `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 -> To be filled on completion \ No newline at end of file +> `src/key_source.rs` (lib re-exports `KeySource`, `HostKeySource`, +> `VaultKeySource`, `VaultHostKeySource`) — ADR-005 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. \ No newline at end of file