dig_keystore/hardware/double.rs
1//! [`FakeDevice`] — a configurable [`HardwareProvider`] double.
2//!
3//! # Why a double, and what it does and does not prove
4//!
5//! Real TPM / Secure Enclave hardware is absent from CI (this repo's workflows
6//! run `ubuntu-latest` only) and cannot be made to lie on demand. `FakeDevice`
7//! exists to exercise the parts that *are* platform-independent: the tier
8//! decision, the fail-closed rules, the envelope codec, and the cross-machine
9//! binding property.
10//!
11//! It models non-exportability **structurally**: the device key lives only
12//! inside the `FakeDevice` and is never written into an envelope, so a second
13//! device with a different key cannot open the first device's blobs — exactly
14//! the situation of a sealed blob copied to another machine. What the double
15//! cannot prove is that a *real* platform key is non-exportable; that assertion
16//! belongs against the platform itself (it is made by attempting an export and
17//! requiring the platform to refuse).
18//!
19//! The double is deliberately **wide**. A double that can vary only one field
20//! cannot express a multi-field lie, and the interesting failures here are
21//! precisely providers that are inconsistent with themselves: one that probes
22//! `Available` but cannot wrap, one that claims a hardware kind it does not
23//! bind, one that reports `NonExportable` custody over a key held in process
24//! memory.
25
26use zeroize::Zeroizing;
27
28use std::sync::Arc;
29
30use parking_lot::Mutex;
31
32use super::provider::{ContentKey, HardwareProvider, KeyCustody, CONTENT_KEY_LEN};
33use super::tier::{HardwareKind, HardwareProbe};
34use crate::cipher;
35use crate::error::{KeystoreError, Result};
36
37/// How a [`FakeDevice`] behaves when asked to wrap or unwrap.
38///
39/// Each variant is an adversary the tier self-test must refute. The vocabulary is
40/// deliberately wide: **a fixture set that cannot express a given lie reports the
41/// guard against it as safe**, so every clause of the self-test contract needs a
42/// variant that violates exactly that clause.
43#[non_exhaustive]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum WrapBehaviour {
46 /// Correct: AES-256-GCM under the device key, round-trips.
47 #[default]
48 Honest,
49 /// `wrap_key` errors — models hardware that probes present but cannot be used.
50 FailWrap,
51 /// `wrap_key` succeeds, `unwrap_key` errors.
52 FailUnwrap,
53 /// `wrap_key` returns the content key verbatim — models a "wrapping" that
54 /// wraps nothing, leaving the key exportable with the blob.
55 Passthrough,
56 /// `unwrap_key` returns a *different* key rather than failing — models a
57 /// provider whose round-trip silently does not reproduce the key.
58 WrongKeyOnUnwrap,
59
60 /// `wrap_key` returns an **empty** wrapped key while `unwrap_key` still
61 /// reproduces the right one from an internal slot.
62 ///
63 /// Models a provider that emits nothing into the envelope but remembers the
64 /// key out of band. It is the only way to violate *just* the
65 /// "a wrap that returns nothing" clause of the self-test contract: a device
66 /// that returned empty and then failed to unwrap would be refuted by the
67 /// round-trip clause instead, leaving the empty-wrap guard untested. Such a
68 /// provider would write envelopes carrying no wrapped key at all.
69 EmptyWrapWithRecall,
70}
71
72/// A configurable stand-in for a hardware trusted component.
73#[derive(Debug, Clone)]
74pub struct FakeDevice {
75 kind: HardwareKind,
76 probe: HardwareProbe,
77 custody: KeyCustody,
78 behaviour: WrapBehaviour,
79 /// Per-device key. Never leaves this struct and is never written into an
80 /// envelope — this is what makes one device's blobs unopenable by another.
81 ///
82 /// Shared and mutable so a clone of the device observes a
83 /// [`rotate_device_key`](FakeDevice::rotate_device_key) — the double's model
84 /// of a trusted component being CLEARED while the same handle is held.
85 device_key: Arc<Mutex<[u8; 32]>>,
86 /// How many more `unwrap_key` calls succeed before the device stops
87 /// unwrapping, or `None` for a device that always works.
88 ///
89 /// A device that is broken from the start is refuted by the constructor
90 /// self-test and never reaches the operations under test, so expressing
91 /// "honest at construction, broken afterwards" needs a budget rather than
92 /// another [`WrapBehaviour`] variant. Shared like the key, for the same
93 /// reason.
94 unwraps_left: Arc<Mutex<Option<usize>>>,
95 /// How many more `probe` calls report the configured outcome before the
96 /// device reports [`HardwareProbe::Indeterminate`], or `None` for a device
97 /// whose probe never changes.
98 ///
99 /// Temporal variation, for the same reason `unwraps_left` exists: a device
100 /// that is uninspectable from the start never reaches the composed paths
101 /// under test, because the FIRST resolution already refuses it. Expressing
102 /// "inspectable once, uninspectable moments later" — a TPM that becomes
103 /// contended between two resolutions — needs a budget rather than a fixed
104 /// probe. Shared like the key, so a clone observes the same countdown.
105 probes_left: Arc<Mutex<Option<usize>>>,
106 /// The last content key this device was asked to wrap.
107 ///
108 /// Serves two purposes: it lets a test assert that the plaintext content key
109 /// does not appear in the stored envelope, and it lets
110 /// [`WrapBehaviour::EmptyWrapWithRecall`] reproduce a key it never wrote out.
111 /// Shared behind an `Arc` so a clone of the device observes the same slot.
112 last_wrapped: Arc<Mutex<Option<[u8; CONTENT_KEY_LEN]>>>,
113}
114
115impl FakeDevice {
116 /// An honest, working device of `kind`, keyed by `device_id`.
117 ///
118 /// Two devices with different `device_id`s stand for two different machines.
119 pub fn working(kind: HardwareKind, device_id: u8) -> Self {
120 Self {
121 kind,
122 probe: HardwareProbe::Available(kind),
123 custody: KeyCustody::NonExportable,
124 behaviour: WrapBehaviour::Honest,
125 device_key: Arc::new(Mutex::new([device_id; 32])),
126 unwraps_left: Arc::default(),
127 probes_left: Arc::default(),
128 last_wrapped: Arc::default(),
129 }
130 }
131
132 /// The last content key this device was asked to wrap, if any.
133 ///
134 /// Test-only observability: it is what makes "the content key never appears
135 /// in the stored bytes" an assertion about the actual key rather than about a
136 /// value the test invented.
137 pub fn last_wrapped_content_key(&self) -> Option<[u8; CONTENT_KEY_LEN]> {
138 *self.last_wrapped.lock()
139 }
140
141 /// A host with definitively no hardware.
142 pub fn absent(kind: HardwareKind) -> Self {
143 Self {
144 probe: HardwareProbe::Absent,
145 ..Self::working(kind, 0)
146 }
147 }
148
149 /// A host whose hardware could not be inspected.
150 pub fn indeterminate(kind: HardwareKind, detail: &str) -> Self {
151 Self {
152 probe: HardwareProbe::indeterminate(detail),
153 ..Self::working(kind, 0)
154 }
155 }
156
157 /// Override the probe outcome.
158 pub fn with_probe(mut self, probe: HardwareProbe) -> Self {
159 self.probe = probe;
160 self
161 }
162
163 /// Override the reported custody.
164 pub fn with_custody(mut self, custody: KeyCustody) -> Self {
165 self.custody = custody;
166 self
167 }
168
169 /// Override the wrap/unwrap behaviour.
170 pub fn with_behaviour(mut self, behaviour: WrapBehaviour) -> Self {
171 self.behaviour = behaviour;
172 self
173 }
174
175 /// Override the advertised kind independently of the probed kind, so the
176 /// double can contradict itself.
177 pub fn with_kind(mut self, kind: HardwareKind) -> Self {
178 self.kind = kind;
179 self
180 }
181
182 /// Replace this device's key material, as a TPM clear / re-enrolment does.
183 ///
184 /// Blobs sealed before the rotation become unopenable by this device, which
185 /// is precisely what a user sees after a firmware update clears the TPM.
186 /// Clones share the slot, so a backend already holding this provider sees
187 /// the change.
188 pub fn rotate_device_key(&self, device_id: u8) {
189 *self.device_key.lock() = [device_id; 32];
190 }
191
192 /// Report the configured probe outcome `n` more times, then report
193 /// [`HardwareProbe::Indeterminate`] for every probe after that.
194 ///
195 /// Models a trusted component that is inspectable when first asked and
196 /// contended moments later — a TPM busy with BitLocker, Credential Guard or
197 /// Windows Hello. A composition that resolves the tier TWICE inspects such a
198 /// device twice and gets two different answers, which is the only way to
199 /// exercise the second resolution's policy handling.
200 #[must_use]
201 pub fn indeterminate_probe_after(self, n: usize) -> Self {
202 *self.probes_left.lock() = Some(n);
203 self
204 }
205
206 /// Succeed at `n` more unwraps, then fail every one after that.
207 ///
208 /// Models a component that is healthy when inspected and unusable moments
209 /// later. The constructor self-test spends exactly one unwrap, so
210 /// `failing_unwrap_after(1)` yields a device that resolves a genuine
211 /// hardware tier and then cannot reopen the next thing it seals.
212 #[must_use]
213 pub fn failing_unwrap_after(self, n: usize) -> Self {
214 *self.unwraps_left.lock() = Some(n);
215 self
216 }
217
218 /// Length of the per-wrap nonce prefixed to a wrapped blob.
219 const NONCE_LEN: usize = 12;
220
221 /// Draw a fresh nonce for one wrap.
222 ///
223 /// A fixed nonce would be wrong even in a double: every wrap encrypts a
224 /// *different* freshly-generated content key under the same device key, so a
225 /// constant nonce is AES-GCM nonce reuse across distinct plaintexts. The
226 /// nonce is prefixed to the wrapped blob so `unwrap_key` can recover it.
227 fn fresh_nonce() -> [u8; Self::NONCE_LEN] {
228 let mut nonce = <[u8; Self::NONCE_LEN]>::default();
229 rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut nonce);
230 nonce
231 }
232}
233
234impl HardwareProvider for FakeDevice {
235 fn kind(&self) -> HardwareKind {
236 self.kind
237 }
238
239 fn probe(&self) -> HardwareProbe {
240 let mut budget = self.probes_left.lock();
241 match budget.as_mut() {
242 Some(0) => HardwareProbe::Indeterminate {
243 detail: "fake device became uninspectable".to_owned(),
244 },
245 Some(left) => {
246 *left -= 1;
247 self.probe.clone()
248 }
249 None => self.probe.clone(),
250 }
251 }
252
253 fn custody(&self) -> KeyCustody {
254 self.custody
255 }
256
257 fn wrap_key(&self, content_key: &ContentKey) -> Result<Vec<u8>> {
258 *self.last_wrapped.lock() = Some(**content_key);
259 match self.behaviour {
260 WrapBehaviour::FailWrap => Err(KeystoreError::HardwareWrapFailed {
261 detail: "fake device refuses to wrap".to_owned(),
262 }),
263 WrapBehaviour::Passthrough => Ok(content_key.to_vec()),
264 // Emits nothing, but see `unwrap_key` — the key is remembered.
265 WrapBehaviour::EmptyWrapWithRecall => Ok(Vec::new()),
266 _ => {
267 // nonce || AES-256-GCM(content key) under the device key.
268 let nonce = Self::fresh_nonce();
269 let device_key = *self.device_key.lock();
270 let sealed = cipher::encrypt(&device_key, &nonce, content_key.as_slice(), b"")?;
271 let mut out = Vec::with_capacity(nonce.len() + sealed.len());
272 out.extend_from_slice(&nonce);
273 out.extend_from_slice(&sealed);
274 Ok(out)
275 }
276 }
277 }
278
279 fn unwrap_key(&self, wrapped: &[u8]) -> Result<ContentKey> {
280 // Spend the unwrap budget first: an exhausted device refuses regardless
281 // of behaviour, which is what makes "healthy at construction, broken
282 // afterwards" expressible.
283 {
284 let mut left = self.unwraps_left.lock();
285 if let Some(remaining) = left.as_mut() {
286 if *remaining == 0 {
287 return Err(KeystoreError::HardwareUnwrapFailed {
288 detail: "fake device stopped unwrapping".to_owned(),
289 });
290 }
291 *remaining -= 1;
292 }
293 }
294 match self.behaviour {
295 WrapBehaviour::FailUnwrap => {
296 return Err(KeystoreError::HardwareUnwrapFailed {
297 detail: "fake device refuses to unwrap".to_owned(),
298 })
299 }
300 WrapBehaviour::WrongKeyOnUnwrap => {
301 // Any key OTHER than the one wrapped; derived, not a literal.
302 let mut wrong = <[u8; CONTENT_KEY_LEN]>::default();
303 rand_core::RngCore::fill_bytes(&mut rand_core::OsRng, &mut wrong);
304 return Ok(Zeroizing::new(wrong));
305 }
306 WrapBehaviour::EmptyWrapWithRecall => {
307 return self.last_wrapped.lock().map(Zeroizing::new).ok_or_else(|| {
308 KeystoreError::HardwareUnwrapFailed {
309 detail: "recall device has wrapped nothing yet".to_owned(),
310 }
311 })
312 }
313 WrapBehaviour::Passthrough => {
314 let bytes: [u8; CONTENT_KEY_LEN] =
315 wrapped
316 .try_into()
317 .map_err(|_| KeystoreError::HardwareUnwrapFailed {
318 detail: "passthrough device got a non-key blob".to_owned(),
319 })?;
320 return Ok(Zeroizing::new(bytes));
321 }
322 _ => {}
323 }
324
325 // Split the prefixed nonce back off. A blob too short to carry one was
326 // not produced by this device.
327 if wrapped.len() <= Self::NONCE_LEN {
328 return Err(KeystoreError::HardwareUnwrapFailed {
329 detail: "wrapped blob is too short to carry a nonce".to_owned(),
330 });
331 }
332 let (nonce, sealed) = wrapped.split_at(Self::NONCE_LEN);
333 let nonce: [u8; Self::NONCE_LEN] = nonce.try_into().expect("checked length");
334
335 // A blob sealed by a *different* device key fails here — the
336 // cross-machine binding guarantee.
337 let device_key = *self.device_key.lock();
338 let plain = cipher::decrypt(&device_key, &nonce, sealed, b"").map_err(|_| {
339 KeystoreError::HardwareUnwrapFailed {
340 detail: "wrapped key was not sealed by this device".to_owned(),
341 }
342 })?;
343 let bytes: [u8; CONTENT_KEY_LEN] =
344 plain
345 .as_slice()
346 .try_into()
347 .map_err(|_| KeystoreError::HardwareUnwrapFailed {
348 detail: "unwrapped key has the wrong length".to_owned(),
349 })?;
350 Ok(Zeroizing::new(bytes))
351 }
352}