dig_keystore/hardware/backend.rs
1//! [`HardwareBoundBackend`] — a [`KeychainBackend`] decorator that binds stored
2//! blobs to the host's hardware trusted component.
3//!
4//! # A tier above, not a replacement
5//!
6//! This wraps any existing backend (file, OS credential store, memory). What it
7//! stores is the *already sealed* keystore blob inside a hardware-wrapped
8//! envelope, so the AES-256-GCM + Argon2id passphrase envelope stays the floor
9//! on every path — a degraded host writes the same bytes it always wrote, never
10//! a bare secret.
11//!
12//! # The tier is decided once, by use
13//!
14//! [`HardwareBoundBackend::new`] probes the provider **and then self-tests it**
15//! (wrap a random key, unwrap it, require the round-trip) before it will report
16//! [`ProtectionTier::Hardware`]. A probe is a claim; the self-test is what makes
17//! the claim refutable. Resolving at construction also means "is this keystore
18//! hardware-bound?" is a settled fact rather than a failure that surfaces
19//! mid-`unlock`.
20
21use std::sync::Arc;
22
23use super::envelope::{self, Envelope};
24use super::provider::{ContentKey, HardwareProvider};
25use super::tier::{DegradeReason, HardwareKind, HardwarePolicy, HardwareProbe, ProtectionTier};
26use crate::backend::{BackendKey, KeychainBackend};
27use crate::error::{KeystoreError, Result};
28
29/// A [`KeychainBackend`] that hardware-binds every blob it stores, degrading to
30/// the underlying software envelope when the host has no usable hardware.
31///
32/// # Example
33///
34/// ```no_run
35/// use std::sync::Arc;
36/// use dig_keystore::backend::{BackendKey, FileBackend, KeychainBackend};
37/// use dig_keystore::hardware::{HardwareBoundBackend, HardwarePolicy};
38///
39/// let inner = FileBackend::new("/var/lib/dig/keys");
40/// // No provider available in this build: opens, and says so honestly.
41/// let backend = HardwareBoundBackend::new(inner, None, HardwarePolicy::Optional)?;
42///
43/// // What the HOST can do — the tier a new write would get.
44/// println!("host: {}", backend.tier());
45///
46/// // What protects THIS key — the only answer fit to show a user, because a
47/// // capable host can still hold a keystore that was never wrapped.
48/// let key = BackendKey::new("identity");
49/// let tier = backend.blob_tier(&key)?;
50/// if tier.is_hardware_bound() {
51/// println!("this key is {tier}");
52/// } else {
53/// // Never claim protection this key does not have.
54/// println!("this key is {tier}");
55/// }
56/// # Ok::<(), dig_keystore::KeystoreError>(())
57/// ```
58pub struct HardwareBoundBackend {
59 /// The storage this decorates.
60 inner: Arc<dyn KeychainBackend>,
61 /// The self-tested provider — present only when [`tier`](Self::tier) is
62 /// [`ProtectionTier::Hardware`], so the two can never disagree.
63 provider: Option<Arc<dyn HardwareProvider>>,
64 /// The truthful, settled protection tier.
65 tier: ProtectionTier,
66}
67
68impl HardwareBoundBackend {
69 /// Decorate `inner`, resolving the protection tier once.
70 ///
71 /// Pass `provider = None` to store through `inner` unchanged while reporting
72 /// [`DegradeReason::NotRequested`].
73 ///
74 /// # Errors
75 ///
76 /// Fails closed rather than silently degrading, per `policy`:
77 /// - [`HardwarePolicy::Required`] — any outcome short of self-tested hardware
78 /// is [`KeystoreError::HardwareRequired`].
79 /// - [`HardwarePolicy::Preferred`] (default) — a *confident* absence degrades,
80 /// but an [`Indeterminate`](HardwareProbe::Indeterminate) probe is
81 /// [`KeystoreError::HardwareProbeIndeterminate`]: "I could not determine
82 /// whether this host has hardware" must not be downgraded into "it has
83 /// none", which would quietly strip protection from a machine that has it.
84 /// - [`HardwarePolicy::Optional`] — always opens, always reports the reason.
85 pub fn new<B: KeychainBackend>(
86 inner: B,
87 provider: Option<Arc<dyn HardwareProvider>>,
88 policy: HardwarePolicy,
89 ) -> Result<Self> {
90 Self::with_inner(Arc::new(inner), provider, policy)
91 }
92
93 /// As [`new`](Self::new), for an already shared backend.
94 pub fn with_inner(
95 inner: Arc<dyn KeychainBackend>,
96 provider: Option<Arc<dyn HardwareProvider>>,
97 policy: HardwarePolicy,
98 ) -> Result<Self> {
99 let tier = resolve_tier(provider.as_deref(), policy)?;
100 // Hold the provider only where the tier actually claims hardware, so a
101 // degraded backend cannot accidentally reach for it later.
102 let provider = if tier.is_hardware_bound() {
103 provider
104 } else {
105 None
106 };
107 Ok(Self {
108 inner,
109 provider,
110 tier,
111 })
112 }
113
114 /// What this **host** is bound to — the tier every *newly written* blob gets.
115 ///
116 /// This is a statement about the machine, not about any particular stored
117 /// key. On a hardware-capable host it reports `Hardware` even if a given
118 /// keystore predates hardware binding and is still a bare §3 blob, because a
119 /// capable host does not retroactively protect bytes already at rest.
120 ///
121 /// **Before telling a user that a specific key is hardware-protected, ask
122 /// [`blob_tier`](Self::blob_tier) instead.** Rendering "protected by your
123 /// TPM" from this method would claim copy-resistance that an unwrapped
124 /// legacy blob does not have.
125 pub fn tier(&self) -> &ProtectionTier {
126 &self.tier
127 }
128
129 /// What protects **the key material stored at `key`**, read from the blob
130 /// itself.
131 ///
132 /// This is the question a UI actually has, and it is not the same as
133 /// [`tier`](Self::tier): a hardware-capable host can hold a keystore written
134 /// before hardware binding existed, which is protected by the passphrase
135 /// envelope alone and *does* open on another machine. Answering from the
136 /// stored bytes is what keeps that distinction honest.
137 ///
138 /// The tier reported is the blob's own, independent of this host: a blob
139 /// sealed by an Apple Secure Enclave reads as `Hardware(MacSecureEnclave)`
140 /// even on Windows. Whether *this* host can open it is a separate question,
141 /// answered by [`read`](KeychainBackend::read).
142 ///
143 /// # Errors
144 ///
145 /// - The inner backend's error if `key` cannot be read (e.g. `NotFound`).
146 /// - [`KeystoreError::MalformedEnvelope`] if the blob claims to be an
147 /// envelope but is structurally invalid.
148 /// - [`KeystoreError::UnknownHardwareClass`] if it was sealed by hardware
149 /// this build cannot name.
150 ///
151 /// Both error cases **fail closed**: a wrapped blob is never reported as
152 /// software-protected just because this build cannot fully classify it.
153 /// Guessing in either direction is what this method exists to avoid.
154 pub fn blob_tier(&self, key: &BackendKey) -> Result<ProtectionTier> {
155 let bytes = self.inner.read(key)?;
156
157 // Not an envelope — the passphrase envelope is all that protects it,
158 // whatever this host is capable of.
159 if !envelope::is_envelope(&bytes) {
160 return Ok(ProtectionTier::Software(DegradeReason::BlobNotWrapped));
161 }
162
163 let env = envelope::decode(&bytes)?;
164 match env.hardware_kind() {
165 Some(kind) => Ok(ProtectionTier::Hardware(kind)),
166 None => Err(KeystoreError::UnknownHardwareClass {
167 wire_id: env.hardware_wire_id,
168 }),
169 }
170 }
171
172 /// The underlying storage, for callers that need it directly.
173 pub fn inner(&self) -> &Arc<dyn KeychainBackend> {
174 &self.inner
175 }
176
177 /// Return the key at `key` to the portable software form, so it opens on a
178 /// host that no longer has this hardware. Returns the tier the blob is in
179 /// afterwards.
180 ///
181 /// # Why this exists
182 ///
183 /// Hardware binding makes the trusted component a **second required
184 /// factor**. A TPM is cleared by a firmware update, a mainboard swap or a
185 /// BIOS reset — routine events — and after one the correct passphrase is no
186 /// longer enough: the sealed blob is unopenable, by design and permanently.
187 /// `unbind` is the way back, and it **must be taken while the hardware still
188 /// answers**. There is no recovery afterwards; that is what non-exportable
189 /// custody means.
190 ///
191 /// Unbinding does not expose a secret. What it stores is the AES-256-GCM +
192 /// Argon2id passphrase envelope that was always the floor (`SPEC.md` §3) —
193 /// the same bytes a host with no hardware writes. It gives up cross-machine
194 /// binding, nothing else.
195 ///
196 /// Nothing is written until the plaintext is in hand, and the result is
197 /// verified from storage before this reports success: telling a user their
198 /// seed is portable when it is not is the one failure here with a
199 /// catastrophic follow-on action, since they may then clear the TPM.
200 ///
201 /// # Errors
202 ///
203 /// - [`KeystoreError::NotHardwareBound`] — the blob is wrapped but this
204 /// backend has no provider to open it (the hardware is already gone).
205 /// - [`KeystoreError::HardwareUnwrapFailed`] — the hardware would not open
206 /// it. The stored bytes are left exactly as they were.
207 /// - [`KeystoreError::HardwareStillBound`] — the write did not take.
208 pub fn unbind(&self, key: &BackendKey) -> Result<ProtectionTier> {
209 let bytes = self.inner.read(key)?;
210 if !envelope::is_envelope(&bytes) {
211 return Ok(ProtectionTier::Software(DegradeReason::BlobNotWrapped));
212 }
213
214 let provider = self
215 .provider
216 .as_deref()
217 .ok_or_else(|| KeystoreError::NotHardwareBound {
218 tier: self.tier.to_string(),
219 })?;
220
221 // Unwrap BEFORE writing anything: a failure here must leave the envelope
222 // untouched, so hardware that comes back (a swapped-back board, a
223 // re-enrolled key) still finds the blob it sealed.
224 let plain = self.unwrap_blob(provider, &bytes)?;
225
226 // Write through `inner`, NOT through `self.write` — which, in the
227 // hardware tier, would seal these bytes straight back into a new
228 // envelope and report a successful unbind that changed nothing.
229 self.inner.write(key, &plain)?;
230
231 // Confirm from storage. A store that accepts a write and keeps the old
232 // bytes (a full disk, a read-only mount) would otherwise leave the user
233 // believing it is safe to retire the trusted component.
234 if envelope::is_envelope(&self.inner.read(key)?) {
235 return Err(KeystoreError::HardwareStillBound { key: key.0.clone() });
236 }
237 Ok(ProtectionTier::Software(DegradeReason::BlobNotWrapped))
238 }
239
240 /// Bind the key at `key` to this host's hardware, migrating a blob written
241 /// before hardware binding existed. Returns the tier the blob is in
242 /// afterwards.
243 ///
244 /// Already-bound blobs are left alone: sealing an envelope inside a second
245 /// envelope would produce a blob whose unwrap yields another envelope, which
246 /// nothing can open.
247 ///
248 /// **This is the operation that can strand a seed**, because it overwrites
249 /// the only copy with bytes only this hardware can open. So the new blob is
250 /// read back from storage and reopened through the hardware BEFORE the call
251 /// reports success, and the previous bytes are restored if it cannot be. See
252 /// [`unbind`](Self::unbind) for the way back out.
253 ///
254 /// # Errors
255 ///
256 /// - [`KeystoreError::NotHardwareBound`] — this backend resolved a software
257 /// tier, so there is no hardware to bind to.
258 /// - [`KeystoreError::HardwareWrapFailed`] / [`KeystoreError::HardwareUnwrapFailed`]
259 /// — the seal could not be made, or could not be proven reopenable. The
260 /// previous bytes are restored in both cases.
261 pub fn bind(&self, key: &BackendKey) -> Result<ProtectionTier> {
262 let bytes = self.inner.read(key)?;
263 let provider = self
264 .provider
265 .as_deref()
266 .ok_or_else(|| KeystoreError::NotHardwareBound {
267 tier: self.tier.to_string(),
268 })?;
269
270 if envelope::is_envelope(&bytes) {
271 return Ok(ProtectionTier::Hardware(provider.kind()));
272 }
273
274 let sealed = self.wrap_blob(provider, &bytes)?;
275 self.inner.write(key, &sealed)?;
276
277 // Prove the migration from STORAGE, not from the value just computed: a
278 // seal this hardware cannot reopen has destroyed the only copy, and a
279 // success returned over that is the worst outcome this module has.
280 match self.reopens_to(provider, key, &bytes) {
281 Ok(()) => Ok(ProtectionTier::Hardware(provider.kind())),
282 Err(e) => {
283 // Put the openable bytes back. The restore is best-effort, but
284 // its failure must not mask the reason the bind was rejected.
285 let _ = self.inner.write(key, &bytes);
286 Err(e)
287 }
288 }
289 }
290
291 /// Whether the blob now stored at `key` unwraps, through the hardware, to
292 /// exactly `expected`.
293 fn reopens_to(
294 &self,
295 provider: &dyn HardwareProvider,
296 key: &BackendKey,
297 expected: &[u8],
298 ) -> Result<()> {
299 let stored = self.inner.read(key)?;
300 let reopened = self.unwrap_blob(provider, &stored)?;
301 if reopened != expected {
302 return Err(KeystoreError::HardwareUnwrapFailed {
303 detail: "the newly sealed blob did not reopen to the original bytes".to_owned(),
304 });
305 }
306 Ok(())
307 }
308
309 /// Seal `blob` into a hardware envelope. Only reachable in the hardware tier.
310 fn wrap_blob(&self, provider: &dyn HardwareProvider, blob: &[u8]) -> Result<Vec<u8>> {
311 let mut rng = rand_core::OsRng;
312 let content_key = envelope::random_content_key(&mut rng);
313 let nonce = envelope::random_nonce(&mut rng);
314 let wrapped_key = provider.wrap_key(&content_key)?;
315 envelope::encode(provider.kind(), &content_key, &wrapped_key, &nonce, blob)
316 }
317
318 /// Open a hardware envelope, requiring the hardware that sealed it.
319 fn unwrap_blob(&self, provider: &dyn HardwareProvider, bytes: &[u8]) -> Result<Vec<u8>> {
320 let env = envelope::decode(bytes)?;
321 require_matching_hardware(&env, provider.kind())?;
322 let content_key: ContentKey = provider.unwrap_key(&env.wrapped_key)?;
323 Ok(env.open(&content_key)?.to_vec())
324 }
325}
326
327/// Reject an envelope sealed by hardware other than ours before spending a
328/// hardware round-trip on it.
329///
330/// Covers the unrecognised-wire-id case as well as a known-but-different class:
331/// both mean "not openable by this host's component", which is a different fact
332/// from a corrupt file.
333fn require_matching_hardware(env: &Envelope, ours: HardwareKind) -> Result<()> {
334 match env.hardware_kind() {
335 Some(kind) if kind == ours => Ok(()),
336 Some(kind) => Err(KeystoreError::HardwareKindMismatch {
337 expected: ours.label(),
338 found: kind.label(),
339 }),
340 // Not a hardware refusal and not corruption — a class this build cannot
341 // name. Reported as such so `HardwareUnwrapFailed` keeps meaning exactly
342 // "the hardware refused".
343 None => Err(KeystoreError::UnknownHardwareClass {
344 wire_id: env.hardware_wire_id,
345 }),
346 }
347}
348
349/// Decide the protection tier from a provider's probe, its custody claim, and a
350/// live self-test — then apply `policy` to any negative outcome.
351fn resolve_tier(
352 provider: Option<&dyn HardwareProvider>,
353 policy: HardwarePolicy,
354) -> Result<ProtectionTier> {
355 let Some(provider) = provider else {
356 return degrade(DegradeReason::NotRequested, policy);
357 };
358
359 match provider.probe() {
360 HardwareProbe::Absent => degrade(DegradeReason::NoHardwarePresent, policy),
361
362 // "Could not determine" is its own outcome, and the only one that is an
363 // error under the default policy.
364 HardwareProbe::Indeterminate { detail } => {
365 if policy.allows_indeterminate_degrade() {
366 degrade(DegradeReason::ProbeIndeterminate { detail }, policy)
367 } else {
368 Err(KeystoreError::HardwareProbeIndeterminate { detail })
369 }
370 }
371
372 HardwareProbe::Available(kind) => match verify_hardware(provider, kind) {
373 Ok(()) => Ok(ProtectionTier::Hardware(kind)),
374 Err(detail) => degrade(DegradeReason::HardwareUnusable { detail }, policy),
375 },
376 }
377}
378
379/// Refute or confirm a provider's "hardware is available" claim.
380///
381/// Three ways the claim fails, all of which must land on
382/// [`DegradeReason::HardwareUnusable`] rather than a hardware tier:
383/// a provider that disagrees with itself about which component it binds to; a
384/// wrapping key that is not actually non-exportable (which buys none of the
385/// cross-machine binding the tier promises); and a wrap/unwrap round-trip that
386/// does not reproduce the key.
387fn verify_hardware(
388 provider: &dyn HardwareProvider,
389 probed: HardwareKind,
390) -> std::result::Result<(), String> {
391 if provider.kind() != probed {
392 return Err(format!(
393 "provider binds {} but probed {}",
394 provider.kind().label(),
395 probed.label()
396 ));
397 }
398
399 if !provider.custody().is_hardware_grade() {
400 return Err(format!(
401 "wrapping key custody is {:?}, not NonExportable",
402 provider.custody()
403 ));
404 }
405
406 let mut rng = rand_core::OsRng;
407 let probe_key = envelope::random_content_key(&mut rng);
408 let wrapped = provider
409 .wrap_key(&probe_key)
410 .map_err(|e| format!("self-test wrap failed: {e}"))?;
411 if wrapped.is_empty() {
412 return Err("self-test wrap produced no wrapped key".to_owned());
413 }
414 if wrapped.as_slice() == probe_key.as_slice() {
415 return Err("self-test wrap returned the content key verbatim".to_owned());
416 }
417 let recovered = provider
418 .unwrap_key(&wrapped)
419 .map_err(|e| format!("self-test unwrap failed: {e}"))?;
420 if recovered.as_slice() != probe_key.as_slice() {
421 return Err("self-test round-trip did not reproduce the key".to_owned());
422 }
423 Ok(())
424}
425
426/// Apply `policy` to a negative outcome: degrade with the reason, or fail closed.
427fn degrade(reason: DegradeReason, policy: HardwarePolicy) -> Result<ProtectionTier> {
428 if policy.allows_degrade() {
429 Ok(ProtectionTier::Software(reason))
430 } else {
431 Err(KeystoreError::HardwareRequired { reason })
432 }
433}
434
435impl std::fmt::Debug for HardwareBoundBackend {
436 /// Redacted: reports the tier (which is not sensitive and is the point of
437 /// the type) but never the inner store or any key material.
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 f.debug_struct("HardwareBoundBackend")
440 .field("tier", &self.tier)
441 .field("inner", &"<redacted>")
442 .finish()
443 }
444}
445
446impl KeychainBackend for HardwareBoundBackend {
447 /// Read a blob, unwrapping it when it carries a hardware envelope.
448 ///
449 /// A blob **without** the envelope prefix is returned untouched, which is
450 /// what lets every keystore written before this feature — and any future
451 /// inner format — keep opening (§5.1).
452 ///
453 /// A blob **with** an envelope that this host cannot open is an error, never
454 /// the raw envelope bytes: an envelope copied to a machine without the
455 /// sealing hardware must fail loudly rather than hand back ciphertext that a
456 /// caller would then try to parse as a keystore.
457 fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
458 let bytes = self.inner.read(key)?;
459 if !envelope::is_envelope(&bytes) {
460 return Ok(bytes);
461 }
462 match self.provider.as_deref() {
463 Some(provider) => self.unwrap_blob(provider, &bytes),
464 None => Err(KeystoreError::NotHardwareBound {
465 tier: self.tier.to_string(),
466 }),
467 }
468 }
469
470 /// Write a blob, sealing it into a hardware envelope in the hardware tier
471 /// and passing the software-sealed bytes straight through otherwise.
472 fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
473 match self.provider.as_deref() {
474 Some(provider) => {
475 let sealed = self.wrap_blob(provider, data)?;
476 self.inner.write(key, &sealed)
477 }
478 None => self.inner.write(key, data),
479 }
480 }
481
482 fn delete(&self, key: &BackendKey) -> Result<()> {
483 self.inner.delete(key)
484 }
485
486 fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
487 self.inner.list(prefix)
488 }
489
490 fn exists(&self, key: &BackendKey) -> Result<bool> {
491 self.inner.exists(key)
492 }
493}