feat(error): land the fail-closed key-source error taxonomy

- KeySourceError with exactly five variants (Locked, Absent,
  Decryption, UnknownKeyVersion, Decode) — ADR-005 decision 4's
  failure matrix as distinct shapes, the vault taxonomy reshaped
  never collapsed; no catch-all, no Other/Unknown
- payload shapes pinned: Locked/Absent payload-free;
  Decryption/UnknownKeyVersion carry the vault's opaque messages;
  Decode carries a message so the RC-pinned ssh-key error type
  never enters the public API (flagged for the wave-1 gate)
- each variant doc-commented with ADR-005 semantics + fail-closed
  consequence; module stays engine-blind (no vault/russh types)
- no From<VaultServiceError>: non-mappable vault variants would
  force a collapse; w1-key-sources maps explicitly at its seam
- exhaustive-match + Display/Debug/Clone unit tests

Verified: cargo test (default + all-features), cargo clippy
--all-targets --all-features -- -D warnings, cargo fmt --check,
cargo doc --no-deps, taskgraph validate
This commit is contained in:
glm-5.3-flash committed 2026-10-28 06:22:52 +00:00
1 parent 78ae905b21
commit d85299df38
3 files changed
+220 -7

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
//! The crate's error taxonomy — fail-closed, no catch-alls (ADR-005
//! decision 4: the vault's error taxonomy is the adapter's to reshape,
//! never to collapse).
//!
//! Deliberately **not** error types, per the family contracts:
//!
//! - **Auth declines** — dispositions (`Auth::Reject`, ADR-004),
//! never errors; a decline is an authorized outcome, not a failure.
//! - **Wire decline reasons** — SSH-shaped responses to the client
//! (the wave-2 decline core's material), never this taxonomy.
//! - **Config validation** — the OQ-08/serve-config fields are
//! `Option` and decline at request time, not construction time; no
//! config-error type exists unless a need is found.
use thiserror::Error;
/// Every way a vault-backed key source can fail to produce key
/// material (ADR-005 decision 4 — the failure matrix as distinct
/// variants). All paths are fail-closed: a failed load never yields a
/// dial, and a failed host-key load never yields a server.
///
/// The vault itself is a secret store on this path — the error
/// taxonomy here is the adapter's reshaping of the vault's, never a
/// re-export of it. Variants are deliberately closed: no catch-all,
/// no `Other`, no `Unknown`.
#[derive(Debug, Clone, Error)]
pub enum KeySourceError {
/// The vault was locked when the key was requested.
///
/// ADR-005 semantics: the vault participates only at use time
/// (decrypt-at-use), so a locked vault blocks exactly that — no
/// key material can be decrypted, so there is no dial; on the
/// host-key path the load feeds server construction
/// (decrypt-at-construction), so no server is constructed
/// (POC-2 §5). Live sessions are unaffected: the decoded
/// `PrivateKey` is a dial's lifetime, not a vault-coupled lease.
///
/// Fail-closed consequence: operation refused. Never unlock —
/// the vault's lifecycle is the assembly's, never the adapter's.
#[error("vault is locked; no key material available until unlocked")]
Locked,
/// No blob exists at the requested key location.
///
/// ADR-005 semantics: the assembly source has no
/// `EncryptedData` record for the requested key — no vault call
/// is needed to know (POC-2 §3). Distinct from every other
/// variant on purpose: an absent key is a configuration/
/// registration gap, while a locked/decryption failure is a
/// custody condition at an existing record.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// server construction. Never a fallback key or an anonymous
/// dial.
#[error("no key material at the requested key location")]
Absent,
/// Decryption failed — wrong mnemonic or tampered ciphertext.
///
/// ADR-005 semantics: the AES-256-GCM open failed (POC-2 §3):
/// the mnemonic derives an unrelated key, or the ciphertext was
/// altered in flight — the refuse-at-crypto class. The vault
/// reports this class as opaque message text, which is the
/// honest content here.
///
/// Fail-closed consequence: no key material exists, so no dial;
/// on the host-key path, no server construction.
#[error("key material decryption failed: {0}")]
Decryption(String),
/// The record's key version is one the vault cannot resolve.
///
/// ADR-005 semantics: the vault declines *before* touching
/// crypto — an unknown/unresolvable `key_version` (reserved/legacy
/// v1 is the TS_PBKDF2 class; a future version the vault predates
/// is the other class) is refused at derivation-path resolution
/// (POC-2 §3). `EncryptedData` remains opaque: the crate never
/// interprets versions — this variant carries only the vault's
/// refusal message.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// server construction. Rotation (higher version the vault
/// supports) is the storage-side migration — a version the
/// vault cannot derive never becomes a partially usable key.
#[error("vault cannot resolve the record's version: {0}")]
UnknownKeyVersion(String),
/// The decrypted plaintext arrived but is not decodable key
/// material — the one-time [`decode_secret_key`](russh::keys::decode_secret_key)
/// failure.
///
/// ADR-005 semantics: the vault decrypted *something*
/// successfully and the plaintext is not an OpenSSH
/// 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.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// server construction.
#[error("decrypted plaintext is not decodable key material: {0}")]
Decode(String),
}
#[cfg(test)]
mod tests {
use super::KeySourceError;
fn all_variants() -> [KeySourceError; 5] {
[
KeySourceError::Locked,
KeySourceError::Absent,
KeySourceError::Decryption("decryption error: aead::Error".into()),
KeySourceError::UnknownKeyVersion("invalid path: reserved version".into()),
KeySourceError::Decode("PEM parse failed".into()),
]
}
#[test]
fn closed_taxonomy_no_catch_all() {
let exhaustive: fn(&KeySourceError) -> &'static str = |e| match e {
KeySourceError::Locked => "locked",
KeySourceError::Absent => "absent",
KeySourceError::Decryption(_) => "decryption",
KeySourceError::UnknownKeyVersion(_) => "unknown-version",
KeySourceError::Decode(_) => "decode",
};
for v in all_variants() {
assert!(!exhaustive(&v).is_empty());
}
}
#[test]
fn display_is_explicit_and_fail_closed() {
let [locked, absent, decryption, unknown, decode] = all_variants();
assert_eq!(
locked.to_string(),
"vault is locked; no key material available until unlocked"
);
assert_eq!(
absent.to_string(),
"no key material at the requested key location"
);
assert_eq!(
decryption.to_string(),
"key material decryption failed: decryption error: aead::Error"
);
assert_eq!(
unknown.to_string(),
"vault cannot resolve the record's version: invalid path: reserved version"
);
assert_eq!(
decode.to_string(),
"decrypted plaintext is not decodable key material: PEM parse failed"
);
}
#[test]
fn debug_is_informative() {
let [.., decode] = all_variants();
let debug = format!("{decode:?}");
assert!(debug.starts_with("Decode("), "{debug}");
assert!(debug.contains("PEM parse failed"), "{debug}");
}
#[test]
fn clone_carries_payload() {
for v in all_variants() {
let _ = v.clone();
}
}
}
+4
View File
@@ -21,3 +21,7 @@
//! Architecture: `docs/architecture/` (phase 1 — ADRs 001–006 accepted;
//! the dependency surface and feature graph land with implementation
//! start). See also `docs/research/` and family ADR-003.
mod error;
pub use error::KeySourceError;