envseal 0.3.10

Write-only secret vault with process-level access control — post-agent secret management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
//! FIDO2 authenticator integration — cryptographic third factor.
//!
//! envseal's first two factors protect the master key against
//! software adversaries:
//!
//! 1. **Passphrase** (Argon2id-wrapped) — defeats a casual disk
//!    snapshot.
//! 2. **Hardware seal** (DPAPI / Secure Enclave / TPM 2.0) — defeats a
//!    `master.key` exfiltration to a different device.
//!
//! Both are bypassed by an attacker who fully owns the machine *and*
//! captures the user's passphrase (e.g. an agent shell with keylogger
//! capability or a session-hijacked remote desktop). The README's
//! threat-model table flags this as "Partial — detected via
//! `assess_gui_security()`" because the popup gate, while a real
//! deterrent, is a UI-layer mitigation, not a cryptographic one.
//!
//! This module adds a **third factor** that closes that gap:
//! a FIDO2 authenticator's `hmac-secret` extension. The authenticator
//! holds an internal device key that never leaves the silicon. On
//! unlock we send a stored 32-byte salt to the authenticator; it
//! returns `HMAC-SHA256(device_key, salt)` only after a touch /
//! user-verification. We then mix that 32-byte output into the
//! passphrase-derived wrapping key via HKDF.
//!
//! The result: even with full machine compromise plus a captured
//! passphrase, an attacker still cannot decrypt the master key
//! without physical possession of the authenticator AND a fresh
//! user-presence touch on it. The popup gate is no longer the only
//! barrier — there is now a cryptographic one.
//!
//! # On-disk format — v3 envelope
//!
//! When a vault is enrolled with a FIDO2 authenticator, the inner
//! blob (i.e. what the v2 hardware envelope wraps) carries the v3
//! magic `ESV3` and the additional fields:
//!
//! ```text
//! [4 bytes "ESV3"]
//! [2 bytes credential_id_len, big-endian]
//! [N bytes credential_id]    (N >= 1 in practice)
//! [32 bytes hmac_salt]
//! [16 bytes argon2_salt]
//! [12 bytes aes-gcm nonce]
//! [remaining bytes aes-gcm ciphertext+tag]
//! ```
//!
//! v1 inner blobs (legacy) and v2 inner blobs that are simply v1
//! payloads continue to work — the v3 path is opt-in and detected by
//! the `ESV3` magic at offset 0 of the inner blob.
//!
//! # Backwards compatibility (LAW 2)
//!
//! - v1 master.key files: still parse, still unlock, no FIDO2 prompt.
//! - v2 master.key files (hardware-wrapped v1): still parse, still
//!   unlock, no FIDO2 prompt.
//! - v3 master.key files: require a `Fido2Authenticator` to be
//!   supplied to `unlock_master_key_with` (otherwise the unlock
//!   returns [`Error::Fido2Required`]).
//!
//! # Feature gating
//!
//! This module is only compiled when the `fido2` cargo feature is
//! enabled. The default build is unaffected. Tests that exercise the
//! v3 envelope and the trait use the in-tree mock authenticator and
//! enable the feature in `dev-dependencies`.

// `expect()` is denied at the crate root for production code; this
// module uses it only on operations whose failure modes are
// statically impossible (HKDF-SHA256 expand for 32-byte output is
// mathematically infallible per RFC 5869 §2.3; fixed-size slice
// reads after a length-bounding check cannot fail). Clippy can't
// see the proof, so we lift the denial here. `panic` and `unwrap`
// remain denied — the relaxation is scoped to `expect`.
#![allow(clippy::expect_used)]
#![allow(clippy::missing_panics_doc)]

use std::convert::TryInto;

use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::Zeroizing;

use crate::error::Error;

/// Magic bytes that identify a v3 inner blob.
///
/// Chosen to be visually distinct from the v2 envelope magic
/// ([`crate::vault::hardware::V2_MAGIC`]) and to make
/// hexdump-debugging immediate (`45 53 56 33` = `ESV3`).
pub const V3_MAGIC: [u8; 4] = *b"ESV3";

/// HMAC salt size produced by FIDO2's `hmac-secret` extension.
///
/// CTAP2.1 §6.5.4 mandates the salt is exactly 32 bytes; smaller
/// inputs are rejected by the authenticator.
pub const HMAC_SALT_LEN: usize = 32;

/// Argon2id salt length — kept identical to the v1 layout so the
/// passphrase derivation parameters are unchanged when FIDO2 is
/// added on top.
pub const ARGON2_SALT_LEN: usize = 16;

/// AES-256-GCM nonce length — kept identical to the v1 layout.
pub const NONCE_LEN: usize = 12;

/// Maximum length of a FIDO2 credential ID we are willing to embed
/// in the envelope.
///
/// CTAP2.1 caps credential IDs at 1023 bytes; `YubiKeys` typically
/// produce 64 or 128 byte IDs. We hard-cap at 1023 to prevent a
/// malformed file from claiming a multi-MiB id length and triggering
/// unbounded allocation in `parse`.
pub const MAX_CREDENTIAL_ID_LEN: usize = 1023;

/// Minimum total v3 envelope size — magic + length prefix +
/// at least 1 byte of credential id + hmac salt + argon2 salt +
/// nonce + at least the AES-GCM tag. Anything shorter cannot be a
/// real v3 envelope.
pub const MIN_V3_ENVELOPE_LEN: usize = 4 + 2 + 1 + HMAC_SALT_LEN + ARGON2_SALT_LEN + NONCE_LEN + 16;

/// A parsed (or about-to-be-packed) v3 inner envelope.
///
/// All fields are public because the envelope is a wire format: the
/// caller (keychain.rs) is the canonical consumer and reaches in for
/// the named pieces directly.
#[derive(Debug, Clone)]
pub struct V3Envelope {
    /// Opaque credential id returned by `make_credential`. Sent back
    /// to the authenticator during `assert_with_hmac` so the
    /// authenticator knows which internal key to use. Public — the id
    /// itself is not a secret; it is the index of a per-vault
    /// authenticator key.
    pub credential_id: Vec<u8>,
    /// 32-byte salt fed into the `hmac-secret` extension. The
    /// authenticator returns `HMAC-SHA256(device_key, hmac_salt)`.
    /// Salt is public — its purpose is to bind a particular vault
    /// to a particular authenticator-internal key.
    pub hmac_salt: [u8; HMAC_SALT_LEN],
    /// Argon2id salt used to derive the passphrase-half of the
    /// wrapping key. Identical role to the v1 salt.
    pub argon2_salt: [u8; ARGON2_SALT_LEN],
    /// AES-256-GCM nonce for the master-key ciphertext.
    pub nonce: [u8; NONCE_LEN],
    /// AES-256-GCM ciphertext+tag wrapping the 32-byte master key.
    pub ciphertext: Vec<u8>,
}

/// Quick check: is this byte slice plausibly a v3 envelope?
///
/// Used by the unlock path to branch between v1 and v3 layouts
/// after the hardware envelope has been stripped.
#[must_use]
pub fn is_v3(raw: &[u8]) -> bool {
    raw.len() >= V3_MAGIC.len() && raw[..V3_MAGIC.len()] == V3_MAGIC
}

/// Serialize a [`V3Envelope`] into its wire format.
///
/// # Errors
///
/// Returns [`Error::CryptoFailure`] if `credential_id` exceeds
/// [`MAX_CREDENTIAL_ID_LEN`] or is empty.
pub fn pack(env: &V3Envelope) -> Result<Vec<u8>, Error> {
    if env.credential_id.is_empty() {
        return Err(Error::CryptoFailure(
            "FIDO2 v3 envelope refused: credential_id is empty".to_string(),
        ));
    }
    if env.credential_id.len() > MAX_CREDENTIAL_ID_LEN {
        return Err(Error::CryptoFailure(format!(
            "FIDO2 v3 envelope refused: credential_id is {} bytes \
             (max {MAX_CREDENTIAL_ID_LEN})",
            env.credential_id.len()
        )));
    }
    let mut out = Vec::with_capacity(
        V3_MAGIC.len()
            + 2
            + env.credential_id.len()
            + HMAC_SALT_LEN
            + ARGON2_SALT_LEN
            + NONCE_LEN
            + env.ciphertext.len(),
    );
    out.extend_from_slice(&V3_MAGIC);
    let id_len_u16: u16 = env
        .credential_id
        .len()
        .try_into()
        .expect("credential_id length already bounded by MAX_CREDENTIAL_ID_LEN above");
    out.extend_from_slice(&id_len_u16.to_be_bytes());
    out.extend_from_slice(&env.credential_id);
    out.extend_from_slice(&env.hmac_salt);
    out.extend_from_slice(&env.argon2_salt);
    out.extend_from_slice(&env.nonce);
    out.extend_from_slice(&env.ciphertext);
    Ok(out)
}

/// Deserialize a v3 envelope from its wire format.
///
/// # Errors
///
/// Returns [`Error::CryptoFailure`] if the magic is wrong, the
/// length-prefixed credential id overflows the buffer, the
/// remaining tail is too short to contain the hmac salt + argon2
/// salt + nonce + at least an AES-GCM tag, or the credential id
/// length exceeds [`MAX_CREDENTIAL_ID_LEN`].
#[allow(clippy::too_many_lines)]
pub fn parse(raw: &[u8]) -> Result<V3Envelope, Error> {
    if !is_v3(raw) {
        return Err(Error::CryptoFailure(
            "v3 envelope: magic bytes 'ESV3' missing — not a v3 master.key inner blob".to_string(),
        ));
    }
    if raw.len() < MIN_V3_ENVELOPE_LEN {
        return Err(Error::CryptoFailure(format!(
            "v3 envelope: {} bytes is shorter than the minimum {MIN_V3_ENVELOPE_LEN}",
            raw.len()
        )));
    }
    let mut cursor = V3_MAGIC.len();
    let id_len = u16::from_be_bytes(
        raw[cursor..cursor + 2]
            .try_into()
            .expect("two-byte slice always fits a u16 — bounded by MIN_V3_ENVELOPE_LEN"),
    ) as usize;
    cursor += 2;
    if id_len == 0 {
        return Err(Error::CryptoFailure(
            "v3 envelope: credential_id length is zero — refusing".to_string(),
        ));
    }
    if id_len > MAX_CREDENTIAL_ID_LEN {
        return Err(Error::CryptoFailure(format!(
            "v3 envelope: credential_id length {id_len} exceeds maximum {MAX_CREDENTIAL_ID_LEN}"
        )));
    }
    if raw.len() < cursor + id_len + HMAC_SALT_LEN + ARGON2_SALT_LEN + NONCE_LEN {
        return Err(Error::CryptoFailure(format!(
            "v3 envelope: declared credential_id length {id_len} \
             would overrun the {} byte buffer",
            raw.len()
        )));
    }
    let credential_id = raw[cursor..cursor + id_len].to_vec();
    cursor += id_len;

    let hmac_salt: [u8; HMAC_SALT_LEN] = raw[cursor..cursor + HMAC_SALT_LEN]
        .try_into()
        .expect("slice length matches HMAC_SALT_LEN — bounded above");
    cursor += HMAC_SALT_LEN;

    let argon2_salt: [u8; ARGON2_SALT_LEN] = raw[cursor..cursor + ARGON2_SALT_LEN]
        .try_into()
        .expect("slice length matches ARGON2_SALT_LEN — bounded above");
    cursor += ARGON2_SALT_LEN;

    let nonce: [u8; NONCE_LEN] = raw[cursor..cursor + NONCE_LEN]
        .try_into()
        .expect("slice length matches NONCE_LEN — bounded above");
    cursor += NONCE_LEN;

    // Tag is 16 bytes minimum for AES-GCM, but ciphertext could be
    // any positive length. We checked the lower bound via
    // MIN_V3_ENVELOPE_LEN; trust the AEAD layer to reject anything
    // structurally unsound past that.
    let ciphertext = raw[cursor..].to_vec();

    Ok(V3Envelope {
        credential_id,
        hmac_salt,
        argon2_salt,
        nonce,
        ciphertext,
    })
}

/// Authenticator that can mint a credential and produce hmac-secret
/// assertions. Implementations: a real `ctap-hid-fido2` backend
/// (commit 2, behind the `fido2-hardware` feature); a deterministic
/// in-process mock used by the test suite (see [`MockAuthenticator`]).
///
/// The trait is the seam that lets the keychain code be agnostic to
/// the authenticator vendor — swap the backend, the cryptography
/// stays identical.
pub trait Fido2Authenticator {
    /// Mint a new credential against this authenticator. The
    /// returned credential id is opaque and stored in the v3
    /// envelope; subsequent assertions present it back so the
    /// authenticator knows which internal key to use.
    ///
    /// `relying_party_id` and `relying_party_name` follow `WebAuthn`
    /// semantics — envseal uses a fixed RP id of `envseal.local` so
    /// vault credentials are namespaced separately from any web
    /// credentials registered with the same authenticator.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Fido2AssertionFailed`] when the authenticator
    /// is missing, the user declines the touch / verification, or
    /// the device returns a CTAP error.
    fn make_credential(
        &mut self,
        relying_party_id: &str,
        relying_party_name: &str,
    ) -> Result<Vec<u8>, Error>;

    /// Compute `HMAC-SHA256(authenticator_internal_key, salt)` for
    /// the given credential id. The 32-byte output is mixed into the
    /// wrapping key. The authenticator MUST require user-presence
    /// (touch) before responding; backends that skip this are
    /// degrading the security claim.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Fido2AssertionFailed`] when the authenticator
    /// is missing, the user declines, or the authenticator does not
    /// recognize the credential id.
    fn assert_with_hmac(
        &self,
        credential_id: &[u8],
        salt: &[u8; HMAC_SALT_LEN],
    ) -> Result<[u8; HMAC_SALT_LEN], Error>;
}

/// HKDF info string used when mixing the FIDO2 hmac-secret output
/// into the passphrase-derived wrapping key. The version suffix
/// (`v3`) means a future revision can derive separately without a
/// migration: bumping the info string yields a different output, so
/// future v4 envelopes are cryptographically distinct from v3 even
/// if they share the same physical inputs.
pub const HKDF_INFO_V3: &[u8] = b"envseal-fido2-wrap-v3";

/// Mix `argon2_output` and `fido2_secret` into a single 32-byte
/// wrapping key.
///
/// Both inputs are required to be 32 bytes. The returned key is the
/// AES-256 key that wraps the master key in the v3 envelope; it is
/// distinct from the v1 wrapping key (which is `argon2_output`
/// directly) so a v3 envelope cannot be unwrapped by the v1 path
/// even if `fido2_secret` was somehow zeroed.
///
/// HKDF-Extract uses the v3 hmac salt as the salt and concatenates
/// the two 32-byte halves as the IKM. This is the standard "combine
/// two secrets" pattern (see RFC 5869 §3.1).
pub fn combine_passphrase_and_fido2(
    argon2_output: &[u8; 32],
    fido2_secret: &[u8; HMAC_SALT_LEN],
    hmac_salt: &[u8; HMAC_SALT_LEN],
) -> Zeroizing<[u8; 32]> {
    // Concatenate the two halves into the IKM. Held in Zeroizing so
    // the temporary copy is wiped along with the output.
    let mut ikm = Zeroizing::new([0u8; 64]);
    ikm[..32].copy_from_slice(argon2_output);
    ikm[32..].copy_from_slice(fido2_secret);

    let hk = Hkdf::<Sha256>::new(Some(hmac_salt), ikm.as_ref());
    let mut out = Zeroizing::new([0u8; 32]);
    hk.expand(HKDF_INFO_V3, out.as_mut())
        .expect("HKDF-Expand with 32-byte output never fails for SHA-256");
    out
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;

    fn synthetic_envelope() -> V3Envelope {
        V3Envelope {
            credential_id: vec![0xAA; 64],
            hmac_salt: [0x11; HMAC_SALT_LEN],
            argon2_salt: [0x22; ARGON2_SALT_LEN],
            nonce: [0x33; NONCE_LEN],
            ciphertext: vec![0x44; 48],
        }
    }

    #[test]
    fn pack_then_parse_round_trips() {
        let env = synthetic_envelope();
        let packed = pack(&env).unwrap();
        let parsed = parse(&packed).unwrap();
        assert_eq!(parsed.credential_id, env.credential_id);
        assert_eq!(parsed.hmac_salt, env.hmac_salt);
        assert_eq!(parsed.argon2_salt, env.argon2_salt);
        assert_eq!(parsed.nonce, env.nonce);
        assert_eq!(parsed.ciphertext, env.ciphertext);
    }

    #[test]
    fn pack_rejects_empty_credential_id() {
        let mut env = synthetic_envelope();
        env.credential_id.clear();
        let err = pack(&env).unwrap_err();
        assert!(err.to_string().contains("empty"), "got {err}");
    }

    #[test]
    fn pack_rejects_oversized_credential_id() {
        let mut env = synthetic_envelope();
        env.credential_id = vec![0; MAX_CREDENTIAL_ID_LEN + 1];
        let err = pack(&env).unwrap_err();
        assert!(err.to_string().contains("max"), "got {err}");
    }

    #[test]
    fn parse_rejects_missing_magic() {
        let mut bad = pack(&synthetic_envelope()).unwrap();
        bad[0] = b'X';
        let err = parse(&bad).unwrap_err();
        assert!(err.to_string().contains("magic"), "got {err}");
    }

    #[test]
    fn parse_rejects_truncated() {
        let packed = pack(&synthetic_envelope()).unwrap();
        let truncated = &packed[..MIN_V3_ENVELOPE_LEN - 1];
        let err = parse(truncated).unwrap_err();
        assert!(err.to_string().contains("shorter"), "got {err}");
    }

    #[test]
    fn parse_rejects_overlong_id_len() {
        // Header claims a 60_000-byte credential id but the buffer
        // is only large enough to be a real-looking envelope
        // (>= MIN_V3_ENVELOPE_LEN). The id-length check must fire
        // before any slicing — and definitely before any allocation
        // that would scale with the claimed length.
        let mut bogus = Vec::new();
        bogus.extend_from_slice(&V3_MAGIC);
        bogus.extend_from_slice(&60_000u16.to_be_bytes());
        bogus.resize(MIN_V3_ENVELOPE_LEN + 16, 0);
        let err = parse(&bogus).unwrap_err();
        assert!(err.to_string().contains("exceeds"), "got {err}");
    }

    #[test]
    fn parse_rejects_zero_id_len() {
        // A v3 envelope with a structurally valid frame but
        // declared id_len = 0 must be rejected — the spec requires
        // a real credential id.
        let mut bogus = Vec::new();
        bogus.extend_from_slice(&V3_MAGIC);
        bogus.extend_from_slice(&0u16.to_be_bytes());
        bogus.resize(MIN_V3_ENVELOPE_LEN + 16, 0);
        let err = parse(&bogus).unwrap_err();
        assert!(err.to_string().contains("zero"), "got {err}");
    }

    #[test]
    fn parse_rejects_id_overrun() {
        // Header claims a 1000-byte id but the buffer is sized at
        // exactly the structural minimum — the declared id alone
        // would consume past the end. Must fail with `overrun`,
        // not panic and not allocate at the claimed scale.
        let mut bogus = Vec::new();
        bogus.extend_from_slice(&V3_MAGIC);
        bogus.extend_from_slice(&1000u16.to_be_bytes());
        bogus.resize(MIN_V3_ENVELOPE_LEN + 16, 0);
        let err = parse(&bogus).unwrap_err();
        assert!(err.to_string().contains("overrun"), "got {err}");
    }

    #[test]
    fn is_v3_distinguishes_v3_from_v1_blob() {
        // A v1 inner blob is 16+12+N raw bytes — random salt at the
        // start, so the magic check almost certainly fails. We
        // construct a salt that happens to start with `ESV3` and
        // confirm the check still works (it's not catastrophic if a
        // v1 vault randomly collides — the parse would fail at the
        // length check — but the disambiguation should be tight).
        let collide = b"ESV3SALTRESTRESTRESTREST";
        assert!(is_v3(collide));
        // And a normal v1 blob with non-magic prefix is correctly
        // identified as not v3.
        let v1_like = b"\x00\x00\x00\x00salt for argonnonce";
        assert!(!is_v3(v1_like));
    }

    #[test]
    fn combine_is_deterministic_and_changes_with_inputs() {
        let argon = [0x11u8; 32];
        let fido = [0x22u8; HMAC_SALT_LEN];
        let salt = [0x33u8; HMAC_SALT_LEN];

        let a = combine_passphrase_and_fido2(&argon, &fido, &salt);
        let b = combine_passphrase_and_fido2(&argon, &fido, &salt);
        assert_eq!(a.as_ref(), b.as_ref(), "deterministic for identical inputs");

        // Flipping any input MUST yield a different wrapping key.
        let mut argon2 = argon;
        argon2[0] ^= 0x01;
        let c = combine_passphrase_and_fido2(&argon2, &fido, &salt);
        assert_ne!(c.as_ref(), a.as_ref());

        let mut fido2 = fido;
        fido2[0] ^= 0x01;
        let d = combine_passphrase_and_fido2(&argon, &fido2, &salt);
        assert_ne!(d.as_ref(), a.as_ref());

        let mut salt2 = salt;
        salt2[0] ^= 0x01;
        let e = combine_passphrase_and_fido2(&argon, &fido, &salt2);
        assert_ne!(e.as_ref(), a.as_ref());
    }

    /// Deterministic mock authenticator used by the test suite. Holds
    /// a single internal `device_key` and computes hmac-secret
    /// assertions directly from it. Real authenticators do the same
    /// thing inside silicon — the difference is that the device key
    /// here is a Vec on the stack, while a YubiKey's is in a secure
    /// element.
    pub(crate) struct MockAuthenticator {
        device_key: [u8; 32],
        registered: std::collections::HashMap<Vec<u8>, [u8; 32]>,
    }

    impl MockAuthenticator {
        pub fn new(device_key: [u8; 32]) -> Self {
            Self {
                device_key,
                registered: std::collections::HashMap::new(),
            }
        }
    }

    impl Fido2Authenticator for MockAuthenticator {
        fn make_credential(
            &mut self,
            _rp_id: &str,
            _rp_name: &str,
        ) -> Result<Vec<u8>, Error> {
            // Real CTAP2 returns a random opaque blob; we mirror that
            // by hashing (device_key || counter) so each call yields
            // a fresh credential id but the mock stays deterministic
            // for a given (key, registration order) pair.
            use sha2::Digest;
            let mut hasher = Sha256::new();
            hasher.update(self.device_key);
            hasher.update((self.registered.len() as u64).to_be_bytes());
            let id = hasher.finalize().to_vec();
            // Per-credential key = HKDF(device_key, info=credential_id).
            // Real authenticators do the same pattern; this gives the
            // mock a per-credential domain so two credentials on the
            // same mock yield independent assertions.
            let hk = Hkdf::<Sha256>::new(None, &self.device_key);
            let mut per_cred = [0u8; 32];
            hk.expand(&id, &mut per_cred).expect("32-byte HKDF expand");
            self.registered.insert(id.clone(), per_cred);
            Ok(id)
        }

        fn assert_with_hmac(
            &self,
            credential_id: &[u8],
            salt: &[u8; HMAC_SALT_LEN],
        ) -> Result<[u8; HMAC_SALT_LEN], Error> {
            let per_cred = self.registered.get(credential_id).ok_or_else(|| {
                Error::Fido2AssertionFailed(
                    "mock authenticator: unknown credential id".to_string(),
                )
            })?;
            use hmac::{Hmac, Mac};
            type HmacSha256 = Hmac<Sha256>;
            let mut mac = HmacSha256::new_from_slice(per_cred).expect("HMAC-SHA256 takes any key");
            mac.update(salt);
            let tag = mac.finalize().into_bytes();
            let mut out = [0u8; HMAC_SALT_LEN];
            out.copy_from_slice(&tag);
            Ok(out)
        }
    }

    #[test]
    fn mock_authenticator_round_trip() {
        let mut auth = MockAuthenticator::new([0x77; 32]);
        let cred = auth.make_credential("envseal.local", "envseal").unwrap();
        let salt = [0x88u8; HMAC_SALT_LEN];

        let a = auth.assert_with_hmac(&cred, &salt).unwrap();
        let b = auth.assert_with_hmac(&cred, &salt).unwrap();
        assert_eq!(a, b, "mock must be deterministic for identical inputs");

        let mut salt2 = salt;
        salt2[0] ^= 0x01;
        let c = auth.assert_with_hmac(&cred, &salt2).unwrap();
        assert_ne!(c, a, "different salt yields different output");
    }

    #[test]
    fn mock_authenticator_rejects_unknown_credential() {
        let auth = MockAuthenticator::new([0x99; 32]);
        let bogus_cred = vec![0u8; 32];
        let err = auth
            .assert_with_hmac(&bogus_cred, &[0u8; HMAC_SALT_LEN])
            .unwrap_err();
        assert!(matches!(err, Error::Fido2AssertionFailed(_)), "got {err:?}");
    }

    #[test]
    fn mock_authenticator_separates_per_credential_keys() {
        let mut auth = MockAuthenticator::new([0xAAu8; 32]);
        let cred1 = auth.make_credential("envseal.local", "v1").unwrap();
        let cred2 = auth.make_credential("envseal.local", "v2").unwrap();
        assert_ne!(cred1, cred2, "two credentials from same mock must differ");
        let salt = [0x55u8; HMAC_SALT_LEN];
        let a = auth.assert_with_hmac(&cred1, &salt).unwrap();
        let b = auth.assert_with_hmac(&cred2, &salt).unwrap();
        assert_ne!(
            a, b,
            "different credentials on same authenticator must produce different secrets"
        );
    }
}