Skip to main content

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}