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}