diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..12d68d7 --- /dev/null +++ b/src/error.rs @@ -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(); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index d72d7e8..d538b8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/tasks/implementation/w1-error-model.md b/tasks/implementation/w1-error-model.md index fbe9903..9422c04 100644 --- a/tasks/implementation/w1-error-model.md +++ b/tasks/implementation/w1-error-model.md @@ -1,7 +1,7 @@ --- id: w1-error-model name: Wave 1 — the error model (fail-closed taxonomy) -status: pending +status: completed depends_on: [w1-manifest] scope: narrow risk: low @@ -39,13 +39,13 @@ message is the honest content), flagged for the wave-1 gate. ## Acceptance Criteria -- [ ] `KeySourceError` has exactly the five pinned variants; no +- [x] `KeySourceError` has exactly the five pinned variants; no catch-all, no `Other`/`Unknown` -- [ ] Doc comments state each variant's ADR-005 semantics and its +- [x] Doc comments state each variant's ADR-005 semantics and its fail-closed consequence -- [ ] Unit tests construct every variant and assert `Display`/`Debug` +- [x] Unit tests construct every variant and assert `Display`/`Debug` paths compile clean -- [ ] `cargo test`, `cargo clippy --all-targets -- -D warnings`, +- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References @@ -56,8 +56,43 @@ message is the honest content), flagged for the wave-1 gate. ## Notes -> To be filled by implementation agent +> Payload shapes pinned (flagged for the wave-1 gate): `Locked` and +> `Absent` are payload-free — no honest content beyond the variant +> itself (the caller knows which source it requested); +> `Decryption(String)` and `UnknownKeyVersion(String)` carry the +> vault's opaque messages (`VaultServiceError::Encryption` / +> `InvalidPath` arrive as strings per POC-2 §3 — string is the honest +> content). `Decode` carries `String` rather than the typed +> `ssh_key::Error` (`russh::keys::decode_secret_key`'s error type): +> the ssh-key RC type is deliberately kept out of the published +> public API — when the RC settles the payload can be re-typed, but +> freezing an RC error type now would be a worse one-way door. The +> module doc comment records the deliberately-not-error classes +> (auth declines = dispositions per ADR-004; wire decline reasons = +> wave-2; config validation = none unless w1-config finds a need — +> the OQ-08 fields decline at request time). No `From` +> blanket impl on purpose: alkvault's taxonomy has variants +> (`Mnemonic`, `Derivation`, `AlreadyUnlocked`, `UnsupportedKeyType`) +> that do not map onto the five without collapsing, and the ADR's +> reshape-never-collapse rule kills the conversion; w1-key-sources +> maps explicitly at its seam. The module stays engine-blind — no +> alkvault or russh types referenced at the error layer. Tests: an +> exhaustive `match` (no wildcard — the closed-variant invariant is +> compile-time enforced) plus `Display` string assertions over all +> five variants, `Debug` output, and `Clone` payload carrying. ## Summary -> To be filled on completion \ No newline at end of file +> `src/error.rs` landed: `KeySourceError` with exactly five variants +> (`Locked`, `Absent`, `Decryption(String)`, `UnknownKeyVersion(String)`, +> `Decode(String)`), each doc-commented with its ADR-005 semantics and +> fail-closed consequence (locked → no dial + no server; absent/decryption/ +> unknown-version/decode → no dial; host-key path → no server +> construction throughout). Re-exported from the crate root +> (`mod error; pub use error::KeySourceError;` — the alktty pattern). +> No catch-all, no `Other/Unknown`, no blanket `From` from the vault's +> taxonomy. Four unit tests (exhaustive match over the enum, +> `Display`, `Debug`, `Clone`). Verified: `cargo test` (4/4 + doc +> tests), `cargo test --all-features`, `cargo clippy --all-targets +> -- -D warnings` (default + all-features), `cargo fmt --check`, +> `cargo doc --no-deps` (no warnings), `taskgraph validate` clean. \ No newline at end of file