qualia_dik/error.rs
1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! Error type of this crate.
17//!
18//! This crate does not depend on Qualia2FA Core, so its error type has to be self-contained.
19//! Downstream crates wrap it in their own `Error` and keep `source()`, so what the device
20//! identity library reported stays visible in the error chain while the versions of the two
21//! crates evolve independently.
22//!
23//! # Distinct variants instead of one generic `Other`
24//!
25//! Callers handle these failures differently: an invalid public key rejects this connection,
26//! a signature mismatch means "the peer's identity changed",
27//! [`Error::UnsupportedPlatform`] suggests changing platform, and
28//! [`Error::HardwareUnavailable`] suggests changing machine or accepting a software key.
29//! Merging them into a single variant would leave the UI able to say only "something went
30//! wrong".
31//!
32//! # Native error codes are not passed through directly
33//!
34//! Native codes (`NTE_*`, `SECURITY_STATUS`, …) are meaningful only on the platform that
35//! produced them, while callers need to know what to do. The variants therefore carry a
36//! cross-platform classification, and the raw code is kept in the `code` field of
37//! [`Error::Hardware`] for diagnostics: dropping it would discard the only handle available
38//! for looking a hardware problem up in vendor documentation.
39//!
40//! # Secrets must not enter error messages
41//!
42//! Every variant that carries a `reason` takes a `&'static str`, so it is impossible **by
43//! type** to embed a private key or anything else taken from input — error messages reach
44//! logs and crash reports. The only variants carrying a `String` are the `label` ones, which
45//! hold a name chosen by the application rather than a secret.
46
47/// Unified result type of this crate.
48pub type Result<T> = core::result::Result<T, Error>;
49
50/// Failures that can arise from device identity handling and the platform key store.
51#[derive(Debug, thiserror::Error)]
52#[non_exhaustive]
53pub enum Error {
54 // ── Device identity (public-key side) ───────────────────────────
55 /// The device public key is not a valid P-256 SEC1 uncompressed point.
56 ///
57 /// Public keys arrive as **external input** (QR codes, the network), so this is a normal
58 /// rejection path rather than an internal inconsistency.
59 #[error("invalid device public key: {reason}")]
60 DevicePublicKeyInvalid {
61 /// Opaque description for logs.
62 reason: &'static str,
63 },
64
65 /// The device signature is malformed, or verification failed.
66 ///
67 /// # Why the two cases are merged
68 ///
69 /// The caller's response is identical (reject this authentication), and distinguishing
70 /// them would give an attacker a small oracle: the difference between "malformed" and
71 /// "did not verify" could be probed.
72 #[error("invalid device signature or verification failed: {reason}")]
73 DeviceSignatureInvalid {
74 /// Opaque description for logs. Must **not** contain the signature value itself.
75 reason: &'static str,
76 },
77
78 // ── Platform key store ──────────────────────────────────────────
79 /// This platform has no backend implementation yet.
80 ///
81 /// Do **not** treat it as a signal that falling back to a software key is allowed
82 /// automatically. Whether software keys are acceptable is a security policy decision that
83 /// requires explicit user consent; a silent fallback would leave the user believing the
84 /// key is hardware-protected.
85 #[error("platform {platform} has no hardware key store backend yet")]
86 UnsupportedPlatform {
87 /// Platform name (a compile-time constant, containing no user data).
88 platform: &'static str,
89 },
90
91 /// This machine has no usable hardware key store (an implementation exists, but it is
92 /// unavailable in this environment).
93 ///
94 /// # It is a **different thing** from [`Error::UnsupportedPlatform`]
95 ///
96 /// Here a different environment would work (a virtual machine without a TPM, a TPM
97 /// disabled by group policy, a key store held exclusively by another process); there a
98 /// different platform is required. Merging them would make the UI give the same advice
99 /// for "change machine" and "change device", which are two different user actions.
100 #[error("no usable hardware key store on this machine: {reason}")]
101 HardwareUnavailable {
102 /// A fixed reason description, or text supplied by the platform.
103 reason: String,
104 },
105
106 /// A platform API returned a failure.
107 #[error("platform key store call failed ({operation}, native code 0x{code:08X})")]
108 Hardware {
109 /// Name of the failing operation, used for diagnostics.
110 operation: &'static str,
111 /// Raw platform error code.
112 ///
113 /// An `i32` rather than a `u32`: Windows `NTSTATUS` / `SECURITY_STATUS` values are
114 /// **signed**, and many NCrypt failures are returned as negative numbers. Storing
115 /// them as `u32` would turn `-1` into `0xFFFFFFFF`, indistinguishable in a log from a
116 /// genuine `0xFFFFFFFF`.
117 code: i32,
118 },
119
120 /// The referenced key does not exist.
121 #[error("key {label} does not exist")]
122 KeyNotFound {
123 /// Logical name of the key (supplied by the caller, containing no secret).
124 label: String,
125 },
126
127 /// The platform reported a failure in its own words.
128 ///
129 /// # Why this is separate from [`Error::Hardware`]
130 ///
131 /// Android reports failures as Java exceptions rather than as numeric codes: the class
132 /// and the message are the whole diagnosis, and there is no code to format. Collapsing
133 /// the two would either invent a code that means nothing or throw the message away —
134 /// and the message is what tells a user apart "this device has no secure hardware" from
135 /// "this key was refused".
136 ///
137 /// The detail is the platform's own text, naming an operation and an exception. The
138 /// platform does not put key material in it.
139 #[error("platform key store call failed ({operation}): {detail}")]
140 Jni {
141 /// Name of the failing operation, used for diagnostics.
142 operation: &'static str,
143 /// What the platform reported. Contains no key material.
144 detail: String,
145 },
146
147 /// A key already exists under this `label`.
148 ///
149 /// # Why this is a separate variant
150 ///
151 /// [`crate::keystore::DeviceKeyStore::open_or_create`] has to distinguish it from **other**
152 /// failures: for that method "already exists" is the desired outcome (the key can simply
153 /// be opened), while a hardware fault is not. Folding it into [`Error::Hardware`] would
154 /// leave that distinction to a comparison of native error codes, which are an
155 /// implementation detail.
156 ///
157 /// Relatedly, `create` **never overwrites** an existing key: overwriting would replace the
158 /// device identity that other devices use to recognise this one.
159 #[error("key {label} already exists")]
160 KeyAlreadyExists {
161 /// Logical name of the key.
162 label: String,
163 },
164
165 /// The digest length passed in does not meet the requirement of the curve.
166 ///
167 /// ECDSA accepts truncated digests, but the truncation rule is fixed per curve and is
168 /// handled inside the implementation. Accepting a digest of the wrong length would only
169 /// let the signing and verification sides disagree about how far to truncate, and such a
170 /// disagreement shows up as "signatures randomly fail to verify".
171 #[error("invalid digest length: expected {expected} bytes, got {actual} bytes")]
172 InvalidDigestLength {
173 /// Expected length.
174 expected: usize,
175 /// Actual length.
176 actual: usize,
177 },
178
179 /// The key label is invalid.
180 ///
181 /// The label becomes an entry name in the **system-wide** key namespace, so it is
182 /// validated against a whitelist. The most direct hazard is NUL truncation: if `\0` were
183 /// allowed, `"a\0b"` and `"a"` would be the same name to a C string, and two different
184 /// labels would map to one device identity.
185 #[error("invalid key label: {reason}")]
186 InvalidKeyLabel {
187 /// Fixed reason description. Contains **no** raw label — it may come from user input.
188 reason: &'static str,
189 },
190
191 /// The public key returned by **hardware** is not a valid P-256 public key.
192 ///
193 /// Separate from [`Error::DevicePublicKeyInvalid`]: that one means "a public key received
194 /// from a peer is invalid" (a normal rejection path), while this one means "the public
195 /// key we just read back from our own hardware is invalid" — an internal inconsistency
196 /// pointing at the platform backend or the parsing logic, **not** a user error.
197 ///
198 /// The two are handled quite differently: the first only requires rejecting this
199 /// connection, the second has to surface an error a developer can see.
200 #[error("hardware returned an invalid public key: {reason}")]
201 InvalidPublicKey {
202 /// Fixed reason description.
203 reason: &'static str,
204 },
205
206 /// The signature length returned by **hardware** is wrong.
207 ///
208 /// As above, this is an internal inconsistency rather than a user error.
209 #[error(
210 "hardware returned an invalid signature length: expected {expected} bytes, got {actual} bytes"
211 )]
212 InvalidSignatureLength {
213 /// Expected length.
214 expected: usize,
215 /// Actual length.
216 actual: usize,
217 },
218
219 /// The wrapped master key could not be turned back into a master key.
220 ///
221 /// # This is not a hardware fault
222 ///
223 /// The bytes are damaged, or they were produced by a different key-encryption key (a
224 /// restored file, a key store that was re-created, a different device). Retrying and
225 /// changing machine do not help, so the variant is deliberately not classified as
226 /// environmental: what the caller has to offer the user is a recovery path, not a
227 /// different computer.
228 ///
229 /// `code` is the platform's own numeric code where it has one, and `0` where it does not
230 /// (the in-memory backend). The code is kept for diagnostics for the same reason
231 /// [`Error::Hardware`] keeps it: a native code is the only handle available for looking
232 /// a failure up in vendor documentation.
233 #[error("the wrapped master key cannot be unwrapped ({reason}, native code 0x{code:08X})")]
234 InvalidWrappedKey {
235 /// Fixed reason description. Contains no key material.
236 reason: &'static str,
237 /// Raw platform error code, or `0` when the backend reports none.
238 code: i32,
239 },
240
241 /// The platform key store returned something that is not master key material.
242 ///
243 /// An internal inconsistency rather than a user error: the store was asked for the
244 /// material it holds, and what came back has the wrong shape. Reported instead of being
245 /// truncated or padded, because a master key of the wrong length would fail later in a
246 /// place that no longer points here.
247 #[error("the platform key store returned an invalid master key: {reason}")]
248 InvalidMasterKey {
249 /// Fixed reason description. Contains no key material.
250 reason: &'static str,
251 },
252
253 /// The platform key store holds the entry but will not return its material.
254 ///
255 /// This is the failure mode of the **direct** form — "the store holds the material and
256 /// hands it back" — on a platform whose key store protects a key by never letting its
257 /// material out. The entry is unusable for that form, and the honest answer is this
258 /// error rather than a store that silently keeps something the caller can never read
259 /// again. The wrapped form exists for exactly this situation.
260 #[error("the platform key store will not return the stored material: {reason}")]
261 KeyNotExportable {
262 /// Fixed reason description. Contains no key material.
263 reason: &'static str,
264 },
265
266 /// The OS random source is unavailable.
267 ///
268 /// Can only occur with the `mock` backend; real backends leave randomness to the hardware,
269 /// which generates keys inside the TPM / Secure Enclave.
270 #[error("the OS random source is unavailable")]
271 RandomnessUnavailable,
272}
273
274impl Error {
275 /// Whether this error means "no hardware protection is available on this machine".
276 ///
277 /// The UI can use it to decide whether to tell the user that the device identity key will
278 /// be stored in software. It does **not** mean that falling back automatically is allowed;
279 /// see [`Error::UnsupportedPlatform`].
280 #[must_use]
281 pub const fn is_missing_hardware(&self) -> bool {
282 matches!(
283 self,
284 Self::UnsupportedPlatform { .. } | Self::HardwareUnavailable { .. }
285 )
286 }
287
288 /// Whether this error means "a different machine or platform would solve it".
289 ///
290 /// It differs from [`Error::is_missing_hardware`], which answers "is there hardware
291 /// protection": this one answers "would retrying help". A platform API failure
292 /// ([`Error::Hardware`]) belongs here, and it is **not** missing hardware — the key store
293 /// may be perfectly fine and only this call failed.
294 #[must_use]
295 pub const fn is_environmental(&self) -> bool {
296 matches!(
297 self,
298 Self::UnsupportedPlatform { .. }
299 | Self::HardwareUnavailable { .. }
300 | Self::Hardware { .. }
301 )
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 /// `is_missing_hardware` is true only for the hardware-unavailable cases.
310 #[test]
311 fn only_hardware_errors_are_classified_as_missing_hardware() {
312 let missing = [
313 Error::UnsupportedPlatform { platform: "linux" },
314 Error::HardwareUnavailable {
315 reason: "no tpm".into(),
316 },
317 ];
318 for error in &missing {
319 assert!(
320 error.is_missing_hardware(),
321 "{error:?} must be classified as missing hardware"
322 );
323 }
324
325 let present = [
326 Error::KeyNotFound {
327 label: "device".into(),
328 },
329 Error::Hardware {
330 operation: "NCryptSignHash",
331 code: 0x8009_000F_u32 as i32,
332 },
333 Error::InvalidDigestLength {
334 expected: 32,
335 actual: 20,
336 },
337 ];
338 for error in &present {
339 assert!(
340 !error.is_missing_hardware(),
341 "{error:?} must not be classified as missing hardware"
342 );
343 }
344 }
345
346 /// The text of a `Hardware` error gives the native code in hexadecimal — a negative
347 /// decimal value cannot be looked up in vendor documentation.
348 #[test]
349 fn hardware_error_shows_hex_code() {
350 let text = Error::Hardware {
351 operation: "NCryptOpenStorageProvider",
352 code: 0x8009_000F_u32 as i32,
353 }
354 .to_string();
355 assert!(text.contains("8009000F"), "actual: {text}");
356 assert!(text.contains("NCryptOpenStorageProvider"), "actual: {text}");
357 }
358
359 /// **Negative** native codes must also print as a lookup-able hexadecimal value.
360 ///
361 /// NCrypt failures are frequently returned as negative numbers. `{:08X}` on an `i32`
362 /// prints the **two's complement**, so `-1` shows up as `FFFFFFFF`, which is exactly how
363 /// Windows documentation writes it.
364 #[test]
365 fn negative_native_codes_print_as_twos_complement() {
366 let text = Error::Hardware {
367 operation: "NCryptOpenKey",
368 code: -1,
369 }
370 .to_string();
371 assert!(text.contains("FFFFFFFF"), "actual: {text}");
372 }
373
374 /// Environment problems and signature problems are kept apart.
375 ///
376 /// The former should lead the user to change device or accept a software key, the latter
377 /// should reject this authentication. Mixing them would make the UI give the same message
378 /// for "the peer is an attacker" and "this machine has no TPM".
379 #[test]
380 fn environmental_failures_are_recognised() {
381 assert!(
382 Error::UnsupportedPlatform { platform: "ios" }.is_environmental(),
383 "an unsupported platform is an environmental problem"
384 );
385 assert!(
386 Error::Hardware {
387 operation: "op",
388 code: 0,
389 }
390 .is_environmental(),
391 "a platform call failure is an environmental problem"
392 );
393 assert!(
394 !Error::DeviceSignatureInvalid { reason: "mismatch" }.is_environmental(),
395 "a verification failure is **not** an environmental problem; it is a security event"
396 );
397 assert!(
398 !Error::DevicePublicKeyInvalid {
399 reason: "off-curve point"
400 }
401 .is_environmental(),
402 "an invalid public key is not an environmental problem"
403 );
404 }
405
406 /// "The peer sent an invalid public key" and "hardware returned an invalid public key"
407 /// are **two** distinct variants.
408 #[test]
409 fn the_two_invalid_public_key_cases_are_distinct() {
410 let from_peer = Error::DevicePublicKeyInvalid {
411 reason: "the point is not on the P-256 curve",
412 };
413 let from_hardware = Error::InvalidPublicKey {
414 reason: "the length is not 65 bytes",
415 };
416 assert!(
417 !from_peer.is_environmental(),
418 "an invalid public key from a peer is a normal rejection path, not an environmental problem"
419 );
420 assert!(
421 !from_hardware.is_environmental(),
422 "an invalid public key from hardware is an internal inconsistency, not an environmental problem"
423 );
424 assert_ne!(from_peer.to_string(), from_hardware.to_string());
425 }
426
427 /// `Error` implements `std::error::Error`, so it can be propagated with `?`.
428 #[test]
429 fn implements_std_error() {
430 fn assert_is_std_error<E: std::error::Error>() {}
431 assert_is_std_error::<Error>();
432 }
433
434 /// A blob that does not unwrap and a store that will not hand the material back are
435 /// **not** environment problems.
436 ///
437 /// Both are answered by offering the user a recovery path or a different form, never by
438 /// telling them to try another machine, which is what `is_environmental` means.
439 #[test]
440 fn master_key_failures_are_not_environmental() {
441 let cases = [
442 Error::InvalidWrappedKey {
443 reason: "the authentication tag does not match",
444 code: 0x8009_0027_u32 as i32,
445 },
446 Error::InvalidMasterKey {
447 reason: "the material is not 32 bytes long",
448 },
449 Error::KeyNotExportable {
450 reason: "the key store refuses to export the private key",
451 },
452 ];
453 for error in &cases {
454 assert!(
455 !error.is_environmental(),
456 "{error:?} must not be classified as environmental"
457 );
458 assert!(
459 !error.is_missing_hardware(),
460 "{error:?} must not be classified as missing hardware"
461 );
462 }
463 }
464
465 /// The message of an unwrapping failure carries the native code in hexadecimal, the
466 /// same way a [`Error::Hardware`] does.
467 #[test]
468 fn invalid_wrapped_key_shows_the_native_code() {
469 let text = Error::InvalidWrappedKey {
470 reason: "the platform rejected the bytes",
471 code: 0x8009_0027_u32 as i32,
472 }
473 .to_string();
474 assert!(text.contains("80090027"), "actual: {text}");
475 }
476
477 /// Error messages carry locatable numeric values and never panic.
478 #[test]
479 fn display_includes_diagnostic_numbers() {
480 let cases: [(Error, &str); 3] = [
481 (
482 Error::Hardware {
483 operation: "NCryptOpenKey",
484 code: 0x8009_0016_u32 as i32,
485 },
486 "0x80090016",
487 ),
488 (Error::UnsupportedPlatform { platform: "linux" }, "linux"),
489 (
490 Error::KeyNotFound {
491 label: "device".into(),
492 },
493 "device",
494 ),
495 ];
496 for (error, needle) in cases {
497 let text = error.to_string();
498 assert!(
499 text.contains(needle),
500 "error text {text:?} does not contain {needle:?}"
501 );
502 }
503 }
504}