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    /// `Keystore::delete` first, or simply `Keystore::change_password`
135    /// + `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 `KeyScheme` declares a fixed `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**. Structural
214    /// problems with the blob ([`MalformedEnvelope`](Self::MalformedEnvelope))
215    /// and unnameable hardware
216    /// ([`UnknownHardwareClass`](Self::UnknownHardwareClass)) are deliberately
217    /// *not* reported here, so this variant keeps that meaning.
218    ///
219    /// # It does NOT say which situation occurred (`SPEC.md` §17.5b)
220    ///
221    /// Two situations with opposite consequences both produce this error:
222    ///
223    /// - a sealed blob presented to a **different machine** — the wrapping key is
224    ///   non-exportable, so the copy cannot be opened. Recoverable: the sealing
225    ///   machine still holds the key. This refusal is the guarantee, not a
226    ///   malfunction.
227    /// - the **sealing machine itself, after its key was destroyed** by a TPM
228    ///   clear, a firmware update or a mainboard swap. The blob is permanently
229    ///   unopenable by anyone, and for a wallet seed that is funds loss.
230    ///
231    /// They are indistinguishable from the error because the envelope records a
232    /// hardware *class* and carries no device identity. So a caller **MUST NOT**
233    /// infer recoverability from this variant, and a user-facing surface **MUST
234    /// NOT** present a reassuring message on it — that is precisely the error the
235    /// irreversible case returns. Say that this host cannot open the blob and
236    /// that the machine which sealed it may be able to *if its trusted component
237    /// is intact*; the distinction is only resolvable out of band.
238    #[error("hardware unwrap failed: {detail}")]
239    HardwareUnwrapFailed {
240        /// Non-secret detail of the failing operation.
241        detail: String,
242    },
243
244    /// A hardware envelope is structurally invalid.
245    ///
246    /// A malformed blob, not a hardware refusal. Notably covers an envelope
247    /// declaring a zero-length wrapped key — which asserts that no hardware key
248    /// protects it, and so must never decode as a hardware envelope.
249    #[error("malformed hardware envelope: {detail}")]
250    MalformedEnvelope {
251        /// Non-secret detail of the structural violation.
252        detail: String,
253    },
254
255    /// A hardware envelope records a hardware class this build cannot name.
256    ///
257    /// A **forward-compatibility** case, not corruption: a newer writer may have
258    /// sealed with hardware this build has no name for. Reported rather than
259    /// guessed, so an unknown class can never be silently treated as a known one
260    /// — or as unprotected.
261    #[error("blob was sealed by an unrecognised hardware class (wire id {wire_id:#04x})")]
262    UnknownHardwareClass {
263        /// The hardware-kind wire id read from the blob.
264        wire_id: u8,
265    },
266
267    /// A hardware-wrapped blob was found on a host with no hardware tier.
268    ///
269    /// Reported instead of returning the envelope bytes, so a copied blob fails
270    /// loudly rather than being mistaken for a corrupt keystore.
271    #[error("blob is hardware-bound but this host is not ({tier})")]
272    NotHardwareBound {
273        /// The tier this host actually has.
274        tier: String,
275    },
276
277    /// An unbind reported by storage as taken, but the stored blob is still a
278    /// hardware envelope.
279    ///
280    /// Its own variant rather than a generic write error because of what the
281    /// caller does next: a user unbinds in order to safely retire the trusted
282    /// component, so "unbound" over a blob that is still bound is the one
283    /// message here that provokes a destructive action. The wording says STILL
284    /// BOUND for that reason.
285    #[error("unbind did not take: the blob at {key} is still hardware-bound")]
286    HardwareStillBound {
287        /// The backend key whose blob is still wrapped.
288        key: String,
289    },
290
291    /// A hardware-wrapped blob was sealed by a different class of hardware.
292    ///
293    /// Distinct from [`HardwareUnwrapFailed`](Self::HardwareUnwrapFailed), which
294    /// is another *device* of the same class: this is another *kind* of
295    /// component (a blob from a Mac read on Windows), which is a migration case
296    /// rather than a copied-blob case.
297    #[error("blob was sealed by {found}, this host has {expected}")]
298    HardwareKindMismatch {
299        /// The hardware class this host binds to.
300        expected: &'static str,
301        /// The hardware class recorded in the blob.
302        found: &'static str,
303    },
304
305    /// A path holding sealed key material is readable or writable by someone
306    /// other than its owner, and the backend could not restrict it.
307    ///
308    /// `FileBackend` requests owner-only permissions on its root directory and
309    /// on every blob it writes, then **verifies** the result rather than
310    /// trusting the request (`SPEC.md` §10.3, conformance C-14). This error is
311    /// what that verification returns when the bits are still permissive — for
312    /// example on a filesystem that does not implement POSIX modes, where
313    /// `chmod` reports success and changes nothing.
314    ///
315    /// It is deliberately fatal rather than a warning. The alternative is a
316    /// `write` that reports success while leaving a keystore blob group- or
317    /// world-readable, which makes the backend's own documented guarantee a
318    /// falsehood. Callers that genuinely accept that exposure should choose a
319    /// different root, not a quieter backend.
320    #[error("{path} has mode {mode:04o}, which grants access beyond its owner; choose a keystore root on a filesystem that honours POSIX modes")]
321    InsecurePermissions {
322        /// The offending path.
323        path: String,
324        /// The permission bits actually observed after the request.
325        mode: u32,
326    },
327
328    /// The keystore root is not a directory the backend is willing to own.
329    ///
330    /// `FileBackend` refuses a root that is a **symbolic link**, or that
331    /// exists as a non-directory, rather than following it. This is the one
332    /// case that is deliberately *not* repaired: a permissive mode is a drift
333    /// the backend can correct in one syscall and then verify, but a symlink
334    /// is a statement about *where the keystore lives*, and the backend has no
335    /// basis for deciding that some other directory is the intended one. Both
336    /// `fs::set_permissions` and `fs::metadata` follow links, so following one
337    /// would mean chmodding a directory chosen by whoever planted the link and
338    /// then sealing an account master seed inside it.
339    ///
340    /// Callers that genuinely want the link's target as their root should pass
341    /// the resolved path, making that choice explicit at the call site.
342    #[error("{path} is not usable as a keystore root: {reason}")]
343    UnsafeRoot {
344        /// The offending path, exactly as configured.
345        path: String,
346        /// Why the path cannot hold a keystore.
347        reason: &'static str,
348    },
349}
350
351impl From<std::io::Error> for KeystoreError {
352    /// Wrap an I/O error as a backend error.
353    ///
354    /// Used liberally through the `?` operator in [`crate::FileBackend`] and
355    /// other `std::io`-backed code paths.
356    fn from(err: std::io::Error) -> Self {
357        KeystoreError::Backend(Arc::new(err))
358    }
359}