Skip to main content

dig_keystore/hardware/
tier.rs

1//! The protection-tier vocabulary: what protects the key material, and how
2//! confident we are about it.
3//!
4//! # Why this module is only types
5//!
6//! The single most dangerous outcome for a hardware-binding feature is a
7//! caller that *believes* a key is hardware-bound when it is not. A UI that
8//! renders "protected by your TPM" over a plain software-wrapped file has
9//! actively misled the user about the security of their wallet. So the honest
10//! answer is encoded in the **type**, not left to a convention:
11//!
12//! - [`ProtectionTier`] is **total** — there is no "unknown" and no `Option`.
13//!   Every keystore has exactly one tier, always answerable.
14//! - [`ProtectionTier::Software`] **carries its [`DegradeReason`] inline**, so
15//!   "we degraded" can never be reported without saying why. A reason held in
16//!   a separate `Option` field would let an `if let Some(reason)` check skip
17//!   silently when the field was absent, and an absent field is more dangerous
18//!   than a wrong one.
19//! - [`HardwareProbe`] distinguishes **`Absent`** ("there is definitively no
20//!   hardware here") from **`Indeterminate`** ("I could not determine whether
21//!   there is hardware"). Collapsing those two is the same defect class as a
22//!   `bool` that cannot say "I could not check": it converts an inspection
23//!   failure into a confident negative.
24
25use std::fmt;
26
27/// A class of OS hardware trusted component that can hold a non-exportable
28/// wrapping key.
29///
30/// The discriminant is a stable wire id: it is written into the
31/// [`envelope`](super::envelope) header, so a blob records which hardware
32/// class sealed it. Values are **append-only** — never renumber or repurpose
33/// one (§5.1: a sealed blob is permanent at-rest data).
34///
35/// `#[non_exhaustive]`: append-only means this WILL grow, so a downstream `match`
36/// must carry a wildcard arm rather than break each time hardware is added.
37#[non_exhaustive]
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum HardwareKind {
40    /// Windows TPM 2.0, reached through the CNG **Platform Crypto Provider**.
41    WindowsTpm20,
42    /// Apple **Secure Enclave** (`kSecAttrTokenIDSecureEnclave`).
43    MacSecureEnclave,
44    /// Linux TPM 2.0 via the tpm2 software stack.
45    LinuxTpm20,
46}
47
48impl HardwareKind {
49    /// Stable on-wire discriminant written into a sealed envelope header.
50    pub const fn wire_id(self) -> u8 {
51        match self {
52            Self::WindowsTpm20 => 0x01,
53            Self::MacSecureEnclave => 0x02,
54            Self::LinuxTpm20 => 0x03,
55        }
56    }
57
58    /// Parse a wire discriminant, or `None` for an id this build does not know.
59    ///
60    /// An unknown id is a *forward*-compatibility case, not corruption: a
61    /// newer writer may have sealed with hardware this build has no name for.
62    /// The caller reports it as unopenable-here rather than as a bad file.
63    pub const fn from_wire_id(id: u8) -> Option<Self> {
64        match id {
65            0x01 => Some(Self::WindowsTpm20),
66            0x02 => Some(Self::MacSecureEnclave),
67            0x03 => Some(Self::LinuxTpm20),
68            _ => None,
69        }
70    }
71
72    /// Short human label for logs and UI.
73    pub const fn label(self) -> &'static str {
74        match self {
75            Self::WindowsTpm20 => "Windows TPM 2.0",
76            Self::MacSecureEnclave => "Apple Secure Enclave",
77            Self::LinuxTpm20 => "Linux TPM 2.0",
78        }
79    }
80}
81
82impl fmt::Display for HardwareKind {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.write_str(self.label())
85    }
86}
87
88/// Why a keystore is software-wrapped rather than hardware-bound.
89///
90/// Always present alongside [`ProtectionTier::Software`] — a degrade is never
91/// reportable without its cause.
92///
93/// `#[non_exhaustive]`: new ways to fail to bind will be discovered, and a
94/// consumer should treat an unfamiliar reason as "not hardware-bound" rather than
95/// fail to compile.
96#[non_exhaustive]
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum DegradeReason {
99    /// The host was inspected successfully and has no usable hardware trusted
100    /// component. This is a *confident* negative.
101    NoHardwarePresent,
102
103    /// The host **could not be inspected**: the probe errored, timed out, or
104    /// returned nothing intelligible. We do not know whether hardware exists.
105    ///
106    /// Distinct from [`NoHardwarePresent`](Self::NoHardwarePresent) on purpose.
107    /// Under any policy stricter than [`HardwarePolicy::Optional`] this is an
108    /// error rather than a degrade (fail closed) — see
109    /// [`HardwarePolicy`].
110    ProbeIndeterminate {
111        /// Non-secret detail of why the probe could not answer.
112        detail: String,
113    },
114
115    /// Hardware was detected but failed its self-test, so it cannot be trusted
116    /// to wrap or later unwrap key material.
117    ///
118    /// A probe that says "present" is a claim, not a proof; this reason exists
119    /// because the claim is verified by use and can be refuted.
120    HardwareUnusable {
121        /// Non-secret detail of the failing operation.
122        detail: String,
123    },
124
125    /// No hardware binding was attempted — the caller supplied no provider or
126    /// explicitly opted out.
127    NotRequested,
128
129    /// **This particular blob** is not hardware-wrapped, whatever the host is
130    /// capable of.
131    ///
132    /// The reason a keystore written before hardware binding existed — or
133    /// written on a host that had none — reports a software tier even on a
134    /// hardware-capable machine. Only rewriting the blob on such a host binds
135    /// it; a capable host does not retroactively protect bytes already at rest.
136    BlobNotWrapped,
137}
138
139impl fmt::Display for DegradeReason {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        match self {
142            Self::NoHardwarePresent => f.write_str("no hardware trusted component on this host"),
143            Self::ProbeIndeterminate { detail } => {
144                write!(f, "could not determine hardware availability: {detail}")
145            }
146            Self::HardwareUnusable { detail } => {
147                write!(f, "hardware present but unusable: {detail}")
148            }
149            Self::NotRequested => f.write_str("hardware binding not requested"),
150            Self::BlobNotWrapped => f.write_str("this key material is not hardware-wrapped"),
151        }
152    }
153}
154
155/// What actually protects a keystore's wrapping key, reported truthfully.
156///
157/// Total by construction: there is no third "unknown" state and no `Option`
158/// wrapper, so a caller can always ask [`is_hardware_bound`](Self::is_hardware_bound)
159/// and get a real answer.
160///
161/// Deliberately **not** `#[non_exhaustive]`, unlike the enums it is built from.
162/// The point of this type is that exactly two outcomes exist and a consumer must
163/// handle both; allowing a wildcard arm would let the software case be swept into
164/// a catch-all, which is precisely the mistake the type exists to prevent. New
165/// nuance belongs in [`DegradeReason`] or [`HardwareKind`], both of which ARE
166/// non-exhaustive.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum ProtectionTier {
169    /// The wrapping key lives in the named hardware component and is
170    /// non-exportable: the sealed blob cannot be opened on another machine.
171    Hardware(HardwareKind),
172
173    /// The wrapping key is the passphrase-derived software envelope
174    /// (AES-256-GCM + Argon2id) — the floor, never a bare file. The
175    /// [`DegradeReason`] says why hardware is not in use.
176    Software(DegradeReason),
177}
178
179impl ProtectionTier {
180    /// Whether the key material is genuinely bound to hardware.
181    ///
182    /// The one question a UI must ask before claiming hardware protection.
183    pub const fn is_hardware_bound(&self) -> bool {
184        matches!(self, Self::Hardware(_))
185    }
186
187    /// The hardware component in use, or `None` when software-wrapped.
188    pub const fn hardware_kind(&self) -> Option<HardwareKind> {
189        match self {
190            Self::Hardware(kind) => Some(*kind),
191            Self::Software(_) => None,
192        }
193    }
194
195    /// Why this keystore is software-wrapped, or `None` when hardware-bound.
196    pub const fn degrade_reason(&self) -> Option<&DegradeReason> {
197        match self {
198            Self::Hardware(_) => None,
199            Self::Software(reason) => Some(reason),
200        }
201    }
202}
203
204impl fmt::Display for ProtectionTier {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        match self {
207            Self::Hardware(kind) => write!(f, "hardware-bound ({kind})"),
208            Self::Software(reason) => write!(f, "software-wrapped ({reason})"),
209        }
210    }
211}
212
213/// The result of asking a host whether it has a usable hardware trusted
214/// component.
215///
216/// Three-valued on purpose. See the module docs: `Absent` and `Indeterminate`
217/// are different facts and must not be collapsed.
218///
219/// `#[non_exhaustive]`: a future probe may report a state these three do not
220/// cover, and that must not be a breaking change.
221#[non_exhaustive]
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub enum HardwareProbe {
224    /// Hardware of this class is present and reachable.
225    Available(HardwareKind),
226    /// Definitively no usable hardware on this host.
227    Absent,
228    /// The probe itself failed — availability is unknown.
229    Indeterminate {
230        /// Non-secret detail of the probe failure.
231        detail: String,
232    },
233}
234
235impl HardwareProbe {
236    /// Build an [`Indeterminate`](Self::Indeterminate) from any displayable
237    /// detail.
238    pub fn indeterminate(detail: impl fmt::Display) -> Self {
239        Self::Indeterminate {
240            detail: detail.to_string(),
241        }
242    }
243}
244
245/// How strictly a caller requires hardware binding.
246///
247/// The policy decides what an [`Absent`](HardwareProbe::Absent) or
248/// [`Indeterminate`](HardwareProbe::Indeterminate) probe *means*, and it is the
249/// place the fail-closed rule is enforced.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
251pub enum HardwarePolicy {
252    /// Hardware is mandatory. Anything other than a self-tested working
253    /// hardware component is an error; the keystore does not open.
254    Required,
255
256    /// **Default.** Prefer hardware, and degrade only on a *confident*
257    /// negative.
258    ///
259    /// An [`Indeterminate`](HardwareProbe::Indeterminate) probe is an **error**,
260    /// not a degrade: silently downgrading "I could not tell" into "there is
261    /// none" would let a transient probe failure quietly strip hardware
262    /// protection from a wallet that has it, and the resulting software blob
263    /// would then be openable anywhere. Failing closed keeps that decision
264    /// with the caller.
265    #[default]
266    Preferred,
267
268    /// Degrade on any negative outcome — including an indeterminate probe —
269    /// but always report the distinguishing [`DegradeReason`].
270    ///
271    /// For callers that must open regardless (recovery tooling, read-only
272    /// inspection). Honest, but permissive.
273    Optional,
274}
275
276impl HardwarePolicy {
277    /// Whether this policy tolerates degrading to software at all.
278    pub const fn allows_degrade(self) -> bool {
279        !matches!(self, Self::Required)
280    }
281
282    /// Whether this policy tolerates degrading when the probe could not
283    /// determine availability.
284    pub const fn allows_indeterminate_degrade(self) -> bool {
285        matches!(self, Self::Optional)
286    }
287}