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}