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}