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<EncryptedData>, None -> Absent) and
  VaultHostKeySource (record set, empty -> Absent) over the shared
  decrypt_and_decode chain: vault.decrypt -> Zeroizing<String> 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
This commit is contained in:
glm-5.3-flash committed 2026-10-28 06:45:56 +00:00
1 parent 149f11d4b2
commit 4d529dbacf
3 files changed
+580 -9

No files matched your search

+513
View File
@@ -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<String>` 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<dyn KeySource>`, 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<PrivateKey, KeySourceError>;
}
/// 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<Vec<PrivateKey>, 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<EncryptedData>,
}
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<EncryptedData>) -> Self {
Self { vault, blob }
}
}
#[async_trait]
impl KeySource for VaultKeySource {
async fn load(&self) -> Result<PrivateKey, KeySourceError> {
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<EncryptedData>,
}
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<EncryptedData>) -> Self {
Self { vault, blobs }
}
}
#[async_trait]
impl HostKeySource for VaultHostKeySource {
async fn load(&self) -> Result<Vec<PrivateKey>, 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<String>` 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<PrivateKey, KeySourceError> {
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<dyn KeySource> = 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<dyn HostKeySource> = 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")
}
}
+2
View File
@@ -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};
+65 -9
View File
@@ -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<String>` → one-time `decode_secret_key`
- [x] Decrypt → `Zeroizing<String>` → 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<PrivateKey, KeySourceError>` (the
> wave-4 dial-config carries `Arc<dyn KeySource>`) and
> `HostKeySource::load() -> Result<Vec<PrivateKey>,
> 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<EncryptedData>` record (`None` → `Absent`, the POC-2 §1
> `KeySource { blob: Option<EncryptedData> }` 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<String>` 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
> `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<String>` → 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.