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;
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user