fix(key-source): decode failures carry a crate-owned message

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).
This commit is contained in:
glm-5.3-flash committed 2026-10-30 11:44:14 +00:00
1 parent 69d888a231
commit a5fb03e7c6
2 files changed
+44 -4

No files matched your search

+6 -3
View File
@@ -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.
+38 -1
View File
@@ -186,7 +186,44 @@ fn decrypt_and_decode(
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()))
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<String>) -> Result<PrivateKey, KeySourceError> {
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