Skip to main content

dig_keystore/
error.rs

1//! Error types for `dig-keystore`.
2//!
3//! All fallible public operations return [`Result<T>`] (= `Result<T, KeystoreError>`).
4//! The variants are designed to let callers distinguish:
5//!
6//! - **User error** — wrong password, wrong scheme type parameter → reshow a prompt.
7//! - **Corruption / tampering** — CRC mismatch, auth-tag failure → refuse and
8//!   alert the operator (possible attack).
9//! - **Configuration error** — KDF params out of bounds, unsupported algorithm.
10//! - **I/O error** — underlying backend could not read / write.
11//!
12//! Each variant carries enough context to be actionable. `Arc<std::io::Error>`
13//! is used for the backend case so `KeystoreError` can implement `Clone`
14//! (useful when passing errors through async channels or broadcasting via
15//! `watch::Sender`).
16
17use std::sync::Arc;
18use thiserror::Error;
19
20/// Result alias used throughout the crate.
21pub type Result<T> = std::result::Result<T, KeystoreError>;
22
23/// Errors produced by keystore operations.
24///
25/// The enum is [`Clone`] so errors can be fanned out through broadcast channels
26/// or bubbled through async traits. The only non-Clone primitive (`std::io::Error`)
27/// is wrapped in `Arc` to preserve clonability.
28///
29/// `#[non_exhaustive]`: this catalog grows as the crate gains capability (six
30/// variants arrived with hardware binding alone), so downstream `match`es must
31/// carry a wildcard arm rather than break on every addition.
32#[non_exhaustive]
33#[derive(Error, Debug, Clone)]
34pub enum KeystoreError {
35    /// An underlying backend I/O operation failed.
36    ///
37    /// This is the catch-all for filesystem errors from [`crate::FileBackend`]
38    /// as well as any future backend (OS keyring / HSM). The wrapped
39    /// [`std::io::Error`] preserves the original [`ErrorKind`](std::io::ErrorKind)
40    /// for callers who want to distinguish e.g. `NotFound` from `PermissionDenied`.
41    #[error("backend I/O error: {0}")]
42    Backend(#[source] Arc<std::io::Error>),
43
44    /// The file's magic prefix did not match any known scheme.
45    ///
46    /// First 6 bytes of a keystore file carry `DIGVK1`, `DIGLW1`, etc. If the
47    /// caller pointed at a non-keystore file (or a future-version file this
48    /// build doesn't understand), decode fails here before any cryptography.
49    #[error("unknown magic; not a DIG keystore file (saw {saw:?})")]
50    UnknownMagic {
51        /// The magic bytes that were actually read.
52        saw: [u8; 6],
53    },
54
55    /// The file's format version is newer or older than this library understands.
56    ///
57    /// Format version is stored as a big-endian `u16` right after the magic.
58    /// This library recognizes [`crate::FORMAT_VERSION_V1`] only.
59    #[error("unsupported format version {found}")]
60    UnsupportedFormat {
61        /// The format version byte read from the file.
62        found: u16,
63    },
64
65    /// The file's key-scheme id does not match the type parameter used to open it.
66    ///
67    /// If the caller opens `Keystore::<BlsSigning>::load(...)` but the file on
68    /// disk is `L1WalletBls` (scheme id `0x0003`), we refuse. This guards
69    /// against accidentally interpreting wallet master seeds as validator
70    /// signing seeds, which would produce perfectly-valid-looking BLS
71    /// signatures that bind to the wrong domain.
72    #[error(
73        "key scheme mismatch: expected {expected:#06x} ({expected_name:?}), file is {found:#06x}"
74    )]
75    SchemeMismatch {
76        /// The scheme expected by the caller (`K::SCHEME_ID`).
77        expected: u16,
78        /// Human-readable expected name (e.g., `"BlsSigning"`).
79        expected_name: &'static str,
80        /// The scheme id actually stored in the file.
81        found: u16,
82    },
83
84    /// The CRC32 checksum at the end of the file did not match.
85    ///
86    /// CRC is computed over every byte of the file except the trailing 4. A
87    /// mismatch indicates disk corruption, partial write, or deliberate
88    /// tampering. It is NOT a cryptographic integrity check (AES-GCM's tag
89    /// is) — CRC is only a fast-fail so we don't burn ~0.5 s on Argon2 for a
90    /// file that's clearly garbage.
91    #[error("CRC32 check failed (stored {stored:#010x}, computed {computed:#010x})")]
92    CrcMismatch {
93        /// The CRC32 read from the file.
94        stored: u32,
95        /// The CRC32 computed over the preceding bytes.
96        computed: u32,
97    },
98
99    /// AES-GCM authentication tag failed.
100    ///
101    /// This is the single error produced for any cryptographic decryption
102    /// failure: wrong password, tampered ciphertext, tampered header (AAD
103    /// mismatch), or truncated payload. We intentionally do NOT distinguish
104    /// these variants at the error level to avoid side-channel leaks.
105    #[error("AES-GCM authentication failed (wrong password or tampered file)")]
106    DecryptFailed,
107
108    /// Argon2 or AES-GCM rejected the provided parameters.
109    ///
110    /// Thrown when [`crate::KdfParams`] has out-of-bounds values (e.g.,
111    /// `memory_kib < 8192`) or when the underlying `argon2` crate returns an
112    /// error (rare — usually only on invalid output size).
113    #[error("invalid KDF params: {0}")]
114    InvalidKdfParams(&'static str),
115
116    /// The file advertised an unsupported KDF algorithm.
117    ///
118    /// Currently only `0x01 = Argon2id` is recognized. Non-`0x01` values are
119    /// reserved for future algorithms (scrypt, bcrypt, balloon).
120    #[error("unsupported KDF id {0:#04x}")]
121    UnsupportedKdf(u8),
122
123    /// The file advertised an unsupported symmetric cipher.
124    ///
125    /// Currently only `0x01 = AES-256-GCM` is recognized. Non-`0x01` values
126    /// are reserved for e.g. ChaCha20-Poly1305.
127    #[error("unsupported cipher id {0:#04x}")]
128    UnsupportedCipher(u8),
129
130    /// `Keystore::create` was called for a path that already exists.
131    ///
132    /// Deliberate: overwriting a keystore file is almost always an operator
133    /// error. Callers that really want to replace a keystore should
134    /// [`crate::Keystore::delete`] first, or simply [`crate::Keystore::change_password`]
135    /// + [`crate::Keystore::rotate_kdf`] which rotate in place.
136    #[error("key path already exists: {0:?}")]
137    AlreadyExists(String),
138
139    /// The decrypted plaintext has the wrong length for the key scheme.
140    ///
141    /// Each [`crate::KeyScheme`] declares a fixed [`SECRET_LEN`](crate::KeyScheme::SECRET_LEN).
142    /// If `unlock` decrypts successfully but the plaintext length disagrees
143    /// with the scheme (e.g., file was encrypted under v1 with a 32-byte seed
144    /// and this build expects 48), we reject. Normally impossible once the
145    /// scheme id check has passed; included for defence in depth.
146    #[error("invalid plaintext length: expected {expected}, got {got}")]
147    InvalidPlaintext {
148        /// Expected byte length.
149        expected: usize,
150        /// Actual byte length read.
151        got: usize,
152    },
153
154    /// The provided seed bytes were malformed (e.g., not a valid BLS seed).
155    ///
156    /// Rarely thrown — `chia-bls::SecretKey::from_seed` accepts any byte
157    /// length — but reserved for schemes where the raw bytes must pass a
158    /// scheme-specific validity check (e.g., `secp256k1` scalar bounds).
159    #[error("invalid seed bytes: {0}")]
160    InvalidSeed(String),
161
162    /// The file's length header claims a payload larger than the file bytes.
163    ///
164    /// Indicates a truncated file (disk full mid-write, network transfer cut,
165    /// etc). Should be rare since we write files atomically via rename, but
166    /// guard anyway.
167    #[error(
168        "file truncated (header claims {claimed} byte payload, only {available} bytes available)"
169    )]
170    Truncated {
171        /// Bytes claimed by the header.
172        claimed: usize,
173        /// Bytes actually available.
174        available: usize,
175    },
176
177    /// The caller required hardware binding, and this host cannot provide it.
178    ///
179    /// Raised by [`HardwarePolicy::Required`](crate::hardware::HardwarePolicy)
180    /// rather than degrading. The
181    /// [`DegradeReason`](crate::hardware::DegradeReason) says which negative
182    /// outcome occurred — "no TPM on this machine" and "the TPM is present but
183    /// unusable" lead a caller to different remedies.
184    #[error("hardware binding required but unavailable: {reason}")]
185    HardwareRequired {
186        /// Why hardware binding could not be established.
187        reason: crate::hardware::DegradeReason,
188    },
189
190    /// The host could not be inspected, so hardware availability is **unknown**.
191    ///
192    /// Deliberately distinct from a confident "no hardware present": collapsing
193    /// the two would turn an inspection failure into a confident negative, and a
194    /// transient probe failure would then silently strip hardware protection
195    /// from a machine that has it. Under the default
196    /// [`Preferred`](crate::hardware::HardwarePolicy::Preferred) policy this
197    /// fails closed instead of degrading.
198    #[error("could not determine hardware availability: {detail}")]
199    HardwareProbeIndeterminate {
200        /// Non-secret detail of the probe failure.
201        detail: String,
202    },
203
204    /// The hardware component refused to wrap a content key.
205    #[error("hardware wrap failed: {detail}")]
206    HardwareWrapFailed {
207        /// Non-secret detail of the failing operation.
208        detail: String,
209    },
210
211    /// The hardware component could not unwrap a stored content key.
212    ///
213    /// This variant means exactly one thing: **the hardware refused**. It is the
214    /// expected error when a sealed blob is copied to a *different machine* —
215    /// the wrapping key is non-exportable, so the copy cannot be opened — and
216    /// that refusal is the guarantee, not a malfunction. Structural problems
217    /// with the blob ([`MalformedEnvelope`](Self::MalformedEnvelope)) and
218    /// unnameable hardware ([`UnknownHardwareClass`](Self::UnknownHardwareClass))
219    /// are deliberately *not* reported here, so this variant keeps its meaning.
220    #[error("hardware unwrap failed: {detail}")]
221    HardwareUnwrapFailed {
222        /// Non-secret detail of the failing operation.
223        detail: String,
224    },
225
226    /// A hardware envelope is structurally invalid.
227    ///
228    /// A malformed blob, not a hardware refusal. Notably covers an envelope
229    /// declaring a zero-length wrapped key — which asserts that no hardware key
230    /// protects it, and so must never decode as a hardware envelope.
231    #[error("malformed hardware envelope: {detail}")]
232    MalformedEnvelope {
233        /// Non-secret detail of the structural violation.
234        detail: String,
235    },
236
237    /// A hardware envelope records a hardware class this build cannot name.
238    ///
239    /// A **forward-compatibility** case, not corruption: a newer writer may have
240    /// sealed with hardware this build has no name for. Reported rather than
241    /// guessed, so an unknown class can never be silently treated as a known one
242    /// — or as unprotected.
243    #[error("blob was sealed by an unrecognised hardware class (wire id {wire_id:#04x})")]
244    UnknownHardwareClass {
245        /// The hardware-kind wire id read from the blob.
246        wire_id: u8,
247    },
248
249    /// A hardware-wrapped blob was found on a host with no hardware tier.
250    ///
251    /// Reported instead of returning the envelope bytes, so a copied blob fails
252    /// loudly rather than being mistaken for a corrupt keystore.
253    #[error("blob is hardware-bound but this host is not ({tier})")]
254    NotHardwareBound {
255        /// The tier this host actually has.
256        tier: String,
257    },
258
259    /// An unbind reported by storage as taken, but the stored blob is still a
260    /// hardware envelope.
261    ///
262    /// Its own variant rather than a generic write error because of what the
263    /// caller does next: a user unbinds in order to safely retire the trusted
264    /// component, so "unbound" over a blob that is still bound is the one
265    /// message here that provokes a destructive action. The wording says STILL
266    /// BOUND for that reason.
267    #[error("unbind did not take: the blob at {key} is still hardware-bound")]
268    HardwareStillBound {
269        /// The backend key whose blob is still wrapped.
270        key: String,
271    },
272
273    /// A hardware-wrapped blob was sealed by a different class of hardware.
274    ///
275    /// Distinct from [`HardwareUnwrapFailed`](Self::HardwareUnwrapFailed), which
276    /// is another *device* of the same class: this is another *kind* of
277    /// component (a blob from a Mac read on Windows), which is a migration case
278    /// rather than a copied-blob case.
279    #[error("blob was sealed by {found}, this host has {expected}")]
280    HardwareKindMismatch {
281        /// The hardware class this host binds to.
282        expected: &'static str,
283        /// The hardware class recorded in the blob.
284        found: &'static str,
285    },
286}
287
288impl From<std::io::Error> for KeystoreError {
289    /// Wrap an I/O error as a backend error.
290    ///
291    /// Used liberally through the `?` operator in [`crate::FileBackend`] and
292    /// other `std::io`-backed code paths.
293    fn from(err: std::io::Error) -> Self {
294        KeystoreError::Backend(Arc::new(err))
295    }
296}