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;
+42 -7
View File
@@ -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<VaultServiceError>`
> 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
> `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.