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 build ships no provider for this platform.** The host was never
130    /// inspected, so nothing is known about whether it has a trusted component.
131    ///
132    /// Deliberately distinct from
133    /// [`NoHardwarePresent`](Self::NoHardwarePresent), which is a *confident
134    /// claim about the machine*. Reporting an unimplemented platform as an
135    /// absence would assert a fact no code here established — the same
136    /// unknown-reported-as-a-confident-negative defect the three-valued
137    /// [`HardwareProbe`](super::HardwareProbe) exists to prevent.
138    ///
139    /// Distinct from [`NotRequested`](Self::NotRequested) too: the caller DID
140    /// ask, and this build could not answer.
141    PlatformUnsupported {
142        /// Non-secret detail naming the platform and what is missing.
143        detail: String,
144    },
145
146    /// **This particular blob** is not hardware-wrapped, whatever the host is
147    /// capable of.
148    ///
149    /// The reason a keystore written before hardware binding existed — or
150    /// written on a host that had none — reports a software tier even on a
151    /// hardware-capable machine. Only rewriting the blob on such a host binds
152    /// it; a capable host does not retroactively protect bytes already at rest.
153    BlobNotWrapped,
154}
155
156impl fmt::Display for DegradeReason {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::NoHardwarePresent => f.write_str("no hardware trusted component on this host"),
160            Self::ProbeIndeterminate { detail } => {
161                write!(f, "could not determine hardware availability: {detail}")
162            }
163            Self::HardwareUnusable { detail } => {
164                write!(f, "hardware present but unusable: {detail}")
165            }
166            Self::NotRequested => f.write_str("hardware binding not requested"),
167            Self::PlatformUnsupported { detail } => {
168                write!(f, "no hardware provider for this platform: {detail}")
169            }
170            Self::BlobNotWrapped => f.write_str("this key material is not hardware-wrapped"),
171        }
172    }
173}
174
175/// What actually protects a keystore's wrapping key, reported truthfully.
176///
177/// Total by construction: there is no third "unknown" state and no `Option`
178/// wrapper, so a caller can always ask [`is_hardware_bound`](Self::is_hardware_bound)
179/// and get a real answer.
180///
181/// Deliberately **not** `#[non_exhaustive]`, unlike the enums it is built from.
182/// The point of this type is that exactly two outcomes exist and a consumer must
183/// handle both; allowing a wildcard arm would let the software case be swept into
184/// a catch-all, which is precisely the mistake the type exists to prevent. New
185/// nuance belongs in [`DegradeReason`] or [`HardwareKind`], both of which ARE
186/// non-exhaustive.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum ProtectionTier {
189    /// The wrapping key lives in the named hardware component and is
190    /// non-exportable: the sealed blob cannot be opened on another machine.
191    Hardware(HardwareKind),
192
193    /// The wrapping key is the passphrase-derived software envelope
194    /// (AES-256-GCM + Argon2id) — the floor, never a bare file. The
195    /// [`DegradeReason`] says why hardware is not in use.
196    Software(DegradeReason),
197}
198
199impl ProtectionTier {
200    /// Whether the key material is genuinely bound to hardware.
201    ///
202    /// The one question a UI must ask before claiming hardware protection.
203    pub const fn is_hardware_bound(&self) -> bool {
204        matches!(self, Self::Hardware(_))
205    }
206
207    /// The hardware component in use, or `None` when software-wrapped.
208    pub const fn hardware_kind(&self) -> Option<HardwareKind> {
209        match self {
210            Self::Hardware(kind) => Some(*kind),
211            Self::Software(_) => None,
212        }
213    }
214
215    /// Why this keystore is software-wrapped, or `None` when hardware-bound.
216    pub const fn degrade_reason(&self) -> Option<&DegradeReason> {
217        match self {
218            Self::Hardware(_) => None,
219            Self::Software(reason) => Some(reason),
220        }
221    }
222}
223
224impl fmt::Display for ProtectionTier {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        match self {
227            Self::Hardware(kind) => write!(f, "hardware-bound ({kind})"),
228            Self::Software(reason) => write!(f, "software-wrapped ({reason})"),
229        }
230    }
231}
232
233/// The result of asking a host whether it has a usable hardware trusted
234/// component.
235///
236/// Three-valued on purpose. See the module docs: `Absent` and `Indeterminate`
237/// are different facts and must not be collapsed.
238///
239/// `#[non_exhaustive]`: a future probe may report a state these three do not
240/// cover, and that must not be a breaking change.
241#[non_exhaustive]
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum HardwareProbe {
244    /// Hardware of this class is present and reachable.
245    Available(HardwareKind),
246    /// Definitively no usable hardware on this host.
247    Absent,
248    /// The probe itself failed — availability is unknown.
249    Indeterminate {
250        /// Non-secret detail of the probe failure.
251        detail: String,
252    },
253}
254
255impl HardwareProbe {
256    /// Build an [`Indeterminate`](Self::Indeterminate) from any displayable
257    /// detail.
258    pub fn indeterminate(detail: impl fmt::Display) -> Self {
259        Self::Indeterminate {
260            detail: detail.to_string(),
261        }
262    }
263}
264
265/// How strictly a caller requires hardware binding.
266///
267/// The policy decides what an [`Absent`](HardwareProbe::Absent) or
268/// [`Indeterminate`](HardwareProbe::Indeterminate) probe *means*, and it is the
269/// place the fail-closed rule is enforced.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271pub enum HardwarePolicy {
272    /// Hardware is mandatory. Anything other than a self-tested working
273    /// hardware component is an error; the keystore does not open.
274    Required,
275
276    /// **Default.** Prefer hardware, and degrade only on a *confident*
277    /// negative.
278    ///
279    /// An [`Indeterminate`](HardwareProbe::Indeterminate) probe is an **error**,
280    /// not a degrade: silently downgrading "I could not tell" into "there is
281    /// none" would let a transient probe failure quietly strip hardware
282    /// protection from a wallet that has it, and the resulting software blob
283    /// would then be openable anywhere. Failing closed keeps that decision
284    /// with the caller.
285    #[default]
286    Preferred,
287
288    /// Degrade on any negative outcome — including an indeterminate probe —
289    /// but always report the distinguishing [`DegradeReason`].
290    ///
291    /// For callers that must open regardless (recovery tooling, read-only
292    /// inspection). Honest, but permissive.
293    Optional,
294}
295
296impl HardwarePolicy {
297    /// Whether this policy tolerates degrading to software at all.
298    pub const fn allows_degrade(self) -> bool {
299        !matches!(self, Self::Required)
300    }
301
302    /// Whether this policy tolerates degrading when the probe could not
303    /// determine availability.
304    pub const fn allows_indeterminate_degrade(self) -> bool {
305        matches!(self, Self::Optional)
306    }
307}