diff --git a/src/error.rs b/src/error.rs index 2d42eb2..103dbc0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -94,9 +94,12 @@ pub enum KeySourceError { /// private-key PEM (decision 1's inner format). Distinct from /// [`Decryption`](KeySourceError::Decryption) on purpose — the /// custody boundary held, what was guarded is not a key. The - /// parse error's message is honest content; the typed parse - /// error is deliberately not preserved so the RC-pinned - /// `ssh-key` error type never enters the crate's public API. + /// underlying parse error's message is deliberately *not* + /// carried — the machinery's text can echo plaintext-shaped + /// material (the `w5-fuzz-harness` finding: the `ssh-key` PPK + /// path interpolates the raw header), so the payload is + /// crate-owned fixed text; the RC-pinned `ssh-key` error type + /// never enters the crate's public API either way. /// /// Fail-closed consequence: no dial; on the host-key path, no /// server construction. diff --git a/src/key_source.rs b/src/key_source.rs index ecede45..2d13b3d 100644 --- a/src/key_source.rs +++ b/src/key_source.rs @@ -186,7 +186,44 @@ fn decrypt_and_decode( 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())) + decode_key_arm(&plaintext) +} + +/// The at-use chain's decode arm — the one-time +/// [`decode_secret_key`] conversion of [`decrypt_and_decode`] +/// (ADR-005 decision 2's single conversion; this function is byte-for- +/// byte that arm, extracted so the arm is the *only* shape the fuzz +/// target drives). `#[doc(hidden)]` fuzz-visible surface +/// (`w5-fuzz-harness`; the recorded exposure decision — the ingress is +/// production-private, so the exposure is the production arm itself, +/// not a fork). Not documented API: the vault-facing surface is +/// exactly [`VaultKeySource`] / [`VaultHostKeySource`]. +/// +/// The `Zeroizing` posture rides the signature: the plaintext arrives +/// already wrapped at the adapter seam (decision 3), the decode reads +/// it once, and nothing is retained — `Ok` carries the decoded +/// [`PrivateKey`], `Err` carries only the typed [`KeySourceError`] +/// class (never the plaintext). +/// +/// The typed `Decode` payload is crate-owned fixed text, deliberately +/// *not* the underlying machinery's message (the `w5-fuzz-harness` +/// finding, 2026-10-30): russh's/`ssh-key`'s PPK parse path +/// (`PpkParseError::Header`) interpolates raw header bytes — the +/// plaintext's own material — into its Display, so a machinery message +/// riding the typed payload would echo decrypted-record material +/// exactly the no-persist posture (decision 7) forbids. The upstream +/// echo ask is recorded with the task; if the machinery gains an +/// echo-free message taxonomy, the mapping can carry its granularity. +#[doc(hidden)] +pub fn decode_key_arm(plaintext: &Zeroizing) -> Result { + decode_secret_key(plaintext, None).map_err(|_| { + KeySourceError::Decode( + "the plaintext is not OpenSSH private-key PEM material — the decode machinery \ + refused it (the machinery's message is not carried: it can echo plaintext \ + material; the upstream echo ask is recorded)" + .to_owned(), + ) + }) } /// The vault's error taxonomy reshaped into ADR-005 decision 4's