From a5fb03e7c62e77f9cd2035835c15110c97537a27 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Fri, 30 Oct 2026 11:44:14 +0000 Subject: [PATCH] fix(key-source): decode failures carry a crate-owned message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzz campaign (w5-fuzz-harness) found russh's ssh-key PPK parse path interpolating the raw header line — the caller's plaintext bytes — into its Display; passing that message through KeySourceError::Decode's payload could echo decrypted-record material into a stored/logged error string (ADR-005 decision 7's textual leak). decode_key_arm (the production decrypt-and-decode decode arm, exposed #[doc(hidden)] for the sidecar) now maps every decode failure to a crate-owned fixed message naming the refusal — the machinery's text never rides the typed payload. The upstream no-echo ask is recorded for russh/ssh-key. Verification: scripts/matrix.sh green; the fuzz sidecar's key_decode campaign re-ran clean (606 s, fork mode, zero crashes). --- src/error.rs | 9 ++++++--- src/key_source.rs | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) 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