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:
1 parent
78ae905b21
commit
d85299df38
3 files changed
+220
-7
No files matched your search
+174
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user