Skip to main content

uselesskey_webauthn/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic WebAuthn ceremony fixtures.
4//!
5//! This crate provides realistic fixture shapes for registration/assertion
6//! testing. It is not a full WebAuthn server implementation.
7
8use ciborium::{ser::into_writer, value::Value};
9use serde_json::json;
10use sha2::{Digest, Sha256};
11use uselesskey_core::Factory;
12
13/// Stable cache domain for WebAuthn fixtures.
14pub const DOMAIN_WEBAUTHN_FIXTURE: &str = "uselesskey:webauthn:fixture:v1";
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum AttestationMode {
18    Packed,
19    SelfAttestation,
20}
21
22impl AttestationMode {
23    fn as_tag(self) -> &'static str {
24        match self {
25            Self::Packed => "packed",
26            Self::SelfAttestation => "self",
27        }
28    }
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct WebAuthnSpec {
33    pub rp_id: String,
34    pub challenge: Vec<u8>,
35    pub credential_id: Vec<u8>,
36    pub authenticator_model: String,
37    pub attestation_mode: AttestationMode,
38}
39
40impl WebAuthnSpec {
41    pub fn packed(rp_id: impl Into<String>, challenge: impl AsRef<[u8]>) -> Self {
42        Self {
43            rp_id: rp_id.into(),
44            challenge: challenge.as_ref().to_vec(),
45            credential_id: b"uk-credential-id".to_vec(),
46            authenticator_model: "UK-PASSKEY-MOCK".to_string(),
47            attestation_mode: AttestationMode::Packed,
48        }
49    }
50
51    pub fn stable_bytes(&self) -> Vec<u8> {
52        let mut out = Vec::new();
53        write_field(&mut out, "rp_id", self.rp_id.as_bytes());
54        write_field(&mut out, "challenge", &self.challenge);
55        write_field(&mut out, "credential_id", &self.credential_id);
56        write_field(
57            &mut out,
58            "authenticator_model",
59            self.authenticator_model.as_bytes(),
60        );
61        write_field(
62            &mut out,
63            "attestation_mode",
64            self.attestation_mode.as_tag().as_bytes(),
65        );
66        out
67    }
68}
69
70#[derive(Clone, Debug)]
71pub struct RegistrationFixture {
72    pub spec: WebAuthnSpec,
73    pub client_data_json: Vec<u8>,
74    pub authenticator_data: Vec<u8>,
75    pub attestation_object: Vec<u8>,
76    pub rp_id_hash: [u8; 32],
77    pub sign_count: u32,
78    pub aaguid: [u8; 16],
79}
80
81#[derive(Clone, Debug)]
82pub struct AssertionFixture {
83    pub spec: WebAuthnSpec,
84    pub client_data_json: Vec<u8>,
85    pub authenticator_data: Vec<u8>,
86    pub signature: Vec<u8>,
87    pub rp_id_hash: [u8; 32],
88    pub sign_count: u32,
89}
90
91pub trait WebAuthnFactoryExt {
92    fn webauthn_registration(
93        &self,
94        label: impl AsRef<str>,
95        spec: WebAuthnSpec,
96    ) -> RegistrationFixture;
97
98    fn webauthn_assertion(&self, label: impl AsRef<str>, spec: WebAuthnSpec) -> AssertionFixture;
99}
100
101impl WebAuthnFactoryExt for Factory {
102    fn webauthn_registration(
103        &self,
104        label: impl AsRef<str>,
105        spec: WebAuthnSpec,
106    ) -> RegistrationFixture {
107        let spec_bytes = spec.stable_bytes();
108        self.get_or_init(
109            DOMAIN_WEBAUTHN_FIXTURE,
110            label.as_ref(),
111            &spec_bytes,
112            "registration",
113            move |seed| build_registration(spec, *seed.bytes()),
114        )
115        .as_ref()
116        .clone()
117    }
118
119    fn webauthn_assertion(&self, label: impl AsRef<str>, spec: WebAuthnSpec) -> AssertionFixture {
120        let spec_bytes = spec.stable_bytes();
121        self.get_or_init(
122            DOMAIN_WEBAUTHN_FIXTURE,
123            label.as_ref(),
124            &spec_bytes,
125            "assertion",
126            move |seed| build_assertion(spec, *seed.bytes()),
127        )
128        .as_ref()
129        .clone()
130    }
131}
132
133fn build_registration(spec: WebAuthnSpec, seed: [u8; 32]) -> RegistrationFixture {
134    let rp_id_hash = sha256_arr(spec.rp_id.as_bytes());
135    let sign_count = deterministic_sign_count(&spec);
136    let aaguid = deterministic_aaguid(&seed, &spec.authenticator_model);
137    let client_data_json = build_client_data_json("webauthn.create", &spec.challenge, &spec.rp_id);
138
139    let credential_public_key = cbor_public_key(&seed);
140    let auth_data = build_authenticator_data(
141        rp_id_hash,
142        sign_count,
143        Some((
144            &aaguid,
145            &spec.credential_id,
146            credential_public_key.as_slice(),
147        )),
148    );
149
150    let att_stmt = Value::Map(vec![
151        (Value::Text("alg".to_string()), Value::Integer((-7).into())),
152        (
153            Value::Text("sig".to_string()),
154            Value::Bytes(mock_signature(
155                &seed,
156                &[auth_data.as_slice(), client_data_json.as_slice()].concat(),
157                b"attestation",
158            )),
159        ),
160    ]);
161
162    let root = Value::Map(vec![
163        (
164            Value::Text("fmt".to_string()),
165            Value::Text(
166                match spec.attestation_mode {
167                    AttestationMode::Packed => "packed",
168                    AttestationMode::SelfAttestation => "self",
169                }
170                .to_string(),
171            ),
172        ),
173        (Value::Text("attStmt".to_string()), att_stmt),
174        (
175            Value::Text("authData".to_string()),
176            Value::Bytes(auth_data.clone()),
177        ),
178    ]);
179
180    let mut attestation_object = Vec::new();
181    into_writer(&root, &mut attestation_object).expect("serialize attestation object");
182
183    RegistrationFixture {
184        spec,
185        client_data_json,
186        authenticator_data: auth_data,
187        attestation_object,
188        rp_id_hash,
189        sign_count,
190        aaguid,
191    }
192}
193
194fn build_assertion(spec: WebAuthnSpec, seed: [u8; 32]) -> AssertionFixture {
195    let rp_id_hash = sha256_arr(spec.rp_id.as_bytes());
196    let sign_count = deterministic_sign_count(&spec).saturating_add(1);
197    let client_data_json = build_client_data_json("webauthn.get", &spec.challenge, &spec.rp_id);
198    let auth_data = build_authenticator_data(rp_id_hash, sign_count, None);
199    let signature = mock_signature(
200        &seed,
201        &[auth_data.as_slice(), client_data_json.as_slice()].concat(),
202        b"assertion",
203    );
204
205    AssertionFixture {
206        spec,
207        client_data_json,
208        authenticator_data: auth_data,
209        signature,
210        rp_id_hash,
211        sign_count,
212    }
213}
214
215fn build_client_data_json(kind: &str, challenge: &[u8], rp_id: &str) -> Vec<u8> {
216    let val = json!({
217        "type": kind,
218        "challenge": base64url(challenge),
219        "origin": format!("https://{rp_id}"),
220        "crossOrigin": false
221    });
222    serde_json::to_vec(&val).expect("serialize clientDataJSON")
223}
224
225fn build_authenticator_data(
226    rp_id_hash: [u8; 32],
227    sign_count: u32,
228    attested: Option<(&[u8; 16], &[u8], &[u8])>,
229) -> Vec<u8> {
230    let mut out = Vec::new();
231    out.extend_from_slice(&rp_id_hash);
232    let mut flags: u8 = 0x01; // user present
233    if attested.is_some() {
234        flags |= 0x40; // attested credential data included
235    }
236    out.push(flags);
237    out.extend_from_slice(&sign_count.to_be_bytes());
238
239    if let Some((aaguid, credential_id, credential_public_key)) = attested {
240        out.extend_from_slice(aaguid);
241        out.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
242        out.extend_from_slice(credential_id);
243        out.extend_from_slice(credential_public_key);
244    }
245
246    out
247}
248
249fn cbor_public_key(seed: &[u8; 32]) -> Vec<u8> {
250    // COSE EC2 public key map shape used by many WebAuthn implementations.
251    let x = sha256_arr(&[seed.as_slice(), b"x"].concat());
252    let y = sha256_arr(&[seed.as_slice(), b"y"].concat());
253
254    let map = Value::Map(
255        vec![
256            (Value::Integer(1.into()), Value::Integer(2.into())), // kty: EC2
257            (Value::Integer(3.into()), Value::Integer((-7).into())), // alg: ES256
258            (Value::Integer((-1).into()), Value::Integer(1.into())), // crv: P-256
259            (Value::Integer((-2).into()), Value::Bytes(x.to_vec())),
260            (Value::Integer((-3).into()), Value::Bytes(y.to_vec())),
261        ]
262        .into_iter()
263        .collect(),
264    );
265    let mut out = Vec::new();
266    into_writer(&map, &mut out).expect("serialize credential public key");
267    out
268}
269
270fn deterministic_sign_count(spec: &WebAuthnSpec) -> u32 {
271    let digest = sha256_arr(&spec.stable_bytes());
272    u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]])
273}
274
275fn deterministic_aaguid(seed: &[u8; 32], model: &str) -> [u8; 16] {
276    let digest = sha256_arr(&[seed.as_slice(), model.as_bytes()].concat());
277    let mut aaguid = [0u8; 16];
278    aaguid.copy_from_slice(&digest[..16]);
279    aaguid
280}
281
282fn mock_signature(seed: &[u8; 32], body: &[u8], context: &[u8]) -> Vec<u8> {
283    let mut h = Sha256::new();
284    h.update(seed);
285    h.update(context);
286    h.update(body);
287    h.finalize().to_vec()
288}
289
290fn base64url(input: &[u8]) -> String {
291    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
292    let mut out = String::new();
293    let mut chunks = input.chunks_exact(3);
294    for chunk in &mut chunks {
295        let n = ((chunk[0] as u32) << 16) + ((chunk[1] as u32) << 8) + chunk[2] as u32;
296        out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
297        out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
298        out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
299        out.push(TABLE[(n & 0x3f) as usize] as char);
300    }
301
302    match chunks.remainder() {
303        [byte] => {
304            let n = (*byte as u32) << 16;
305            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
306            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
307        }
308        [first, second] => {
309            let n = ((*first as u32) << 16) + ((*second as u32) << 8);
310            out.push(TABLE[((n >> 18) & 0x3f) as usize] as char);
311            out.push(TABLE[((n >> 12) & 0x3f) as usize] as char);
312            out.push(TABLE[((n >> 6) & 0x3f) as usize] as char);
313        }
314        [] => {}
315        _ => unreachable!("chunks_exact remainder is shorter than the chunk size"),
316    }
317    out
318}
319
320fn sha256_arr(bytes: &[u8]) -> [u8; 32] {
321    let mut out = [0u8; 32];
322    out.copy_from_slice(&Sha256::digest(bytes));
323    out
324}
325
326fn write_field(out: &mut Vec<u8>, name: &str, value: &[u8]) {
327    out.extend_from_slice(name.as_bytes());
328    out.push(0x1f);
329    if let Ok(short_len) = u16::try_from(value.len()) {
330        out.extend_from_slice(&short_len.to_be_bytes());
331    } else {
332        // Backward-compatible extension:
333        // - values <= u16::MAX keep the original encoding
334        // - longer values use 0xffff + u32 length, avoiding truncation collisions
335        let len32 = u32::try_from(value.len())
336            .expect("webauthn stable_bytes field length exceeds u32::MAX");
337        out.extend_from_slice(&u16::MAX.to_be_bytes());
338        out.extend_from_slice(&len32.to_be_bytes());
339    }
340    out.extend_from_slice(value);
341}
342
343#[cfg(test)]
344mod tests {
345    use ciborium::{de::from_reader, value::Value};
346    use uselesskey_core::Seed;
347
348    use super::*;
349
350    #[test]
351    fn registration_is_deterministic() {
352        let fx = Factory::deterministic(Seed::from_env_value("webauthn-det").unwrap());
353        let spec = WebAuthnSpec::packed("example.com", b"challenge-a");
354
355        let a = fx.webauthn_registration("alice", spec.clone());
356        let b = fx.webauthn_registration("alice", spec);
357
358        assert_eq!(a.attestation_object, b.attestation_object);
359        assert_eq!(a.sign_count, b.sign_count);
360    }
361
362    #[test]
363    fn attestation_object_is_cbor_map() {
364        let fx = Factory::random();
365        let reg = fx.webauthn_registration(
366            "alice",
367            WebAuthnSpec::packed("example.com", b"challenge-cbor"),
368        );
369        let v: Value = from_reader(reg.attestation_object.as_slice()).expect("parse cbor");
370        let m = match v {
371            Value::Map(entries) => entries,
372            _ => panic!("attestation object must be cbor map"),
373        };
374        assert!(m.iter().any(|(k, _)| *k == Value::Text("fmt".to_string())));
375        assert!(
376            m.iter()
377                .any(|(k, _)| *k == Value::Text("authData".to_string()))
378        );
379    }
380
381    #[test]
382    fn assertion_sign_count_monotonic_per_fixture() {
383        let fx = Factory::deterministic(Seed::from_env_value("webauthn-sign-count").unwrap());
384        let spec = WebAuthnSpec::packed("example.com", b"challenge-sign");
385        let reg = fx.webauthn_registration("alice", spec.clone());
386        let assertion = fx.webauthn_assertion("alice", spec);
387        assert_eq!(assertion.sign_count, reg.sign_count.saturating_add(1));
388    }
389
390    #[test]
391    fn client_data_contains_challenge() {
392        let fx = Factory::random();
393        let challenge = b"abc-123";
394        let reg = fx.webauthn_registration("alice", WebAuthnSpec::packed("example.com", challenge));
395        let json: serde_json::Value =
396            serde_json::from_slice(&reg.client_data_json).expect("parse clientDataJSON");
397        assert_eq!(json["challenge"], base64url(challenge));
398        assert_eq!(json["origin"], "https://example.com");
399    }
400
401    #[test]
402    fn attestation_mode_tags_are_stable() {
403        assert_eq!(AttestationMode::Packed.as_tag(), "packed");
404        assert_eq!(AttestationMode::SelfAttestation.as_tag(), "self");
405
406        let mut spec = WebAuthnSpec::packed("example.com", b"challenge-mode");
407        spec.attestation_mode = AttestationMode::SelfAttestation;
408        let stable = spec.stable_bytes();
409
410        assert_contains_bytes(&stable, b"attestation_mode");
411        assert_contains_bytes(&stable, b"self");
412    }
413
414    #[test]
415    fn authenticator_data_layout_matches_webauthn_shape() {
416        let rp_id_hash = [0x11; 32];
417        let sign_count = 0x0102_0304;
418        let aaguid = [0x22; 16];
419        let credential_id = b"cred";
420        let credential_public_key = b"public-key";
421
422        let reg = build_authenticator_data(
423            rp_id_hash,
424            sign_count,
425            Some((&aaguid, credential_id, credential_public_key)),
426        );
427
428        assert_eq!(&reg[..32], &rp_id_hash);
429        assert_eq!(reg[32], 0x41);
430        assert_eq!(&reg[33..37], &sign_count.to_be_bytes());
431        assert_eq!(&reg[37..53], &aaguid);
432        assert_eq!(u16::from_be_bytes(reg[53..55].try_into().unwrap()), 4);
433        assert_eq!(&reg[55..59], credential_id);
434        assert_eq!(&reg[59..], credential_public_key);
435
436        let assertion = build_authenticator_data(rp_id_hash, sign_count, None);
437        assert_eq!(assertion.len(), 37);
438        assert_eq!(&assertion[..32], &rp_id_hash);
439        assert_eq!(assertion[32], 0x01);
440        assert_eq!(&assertion[33..37], &sign_count.to_be_bytes());
441    }
442
443    #[test]
444    fn cbor_public_key_has_ec2_es256_shape() {
445        let encoded = cbor_public_key(&[4_u8; 32]);
446        let v: Value = from_reader(encoded.as_slice()).expect("parse public key cbor");
447        let entries = match v {
448            Value::Map(entries) => entries,
449            _ => panic!("public key must be cbor map"),
450        };
451
452        assert_eq!(
453            value_by_integer_key(&entries, 1),
454            Some(&Value::Integer(2.into()))
455        );
456        assert_eq!(
457            value_by_integer_key(&entries, 3),
458            Some(&Value::Integer((-7).into()))
459        );
460        assert_eq!(
461            value_by_integer_key(&entries, -1),
462            Some(&Value::Integer(1.into()))
463        );
464        let x = bytes_by_integer_key(&entries, -2).expect("x coordinate");
465        let y = bytes_by_integer_key(&entries, -3).expect("y coordinate");
466        assert_eq!(x.len(), 32);
467        assert_eq!(y.len(), 32);
468        assert_ne!(x, y);
469    }
470
471    #[test]
472    fn deterministic_values_are_sha256_derived() {
473        let seed = [3_u8; 32];
474        let mut spec = WebAuthnSpec::packed("example.com", b"challenge-derived");
475        spec.authenticator_model = "UK-MODEL-A".to_string();
476
477        let digest = Sha256::digest(spec.stable_bytes());
478        let expected_count = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
479        let mut aaguid_input = Vec::new();
480        aaguid_input.extend_from_slice(&seed);
481        aaguid_input.extend_from_slice(spec.authenticator_model.as_bytes());
482        let digest = Sha256::digest(aaguid_input);
483        let mut expected_aaguid = [0_u8; 16];
484        expected_aaguid.copy_from_slice(&digest[..16]);
485
486        let reg = build_registration(spec.clone(), seed);
487        assert_eq!(reg.rp_id_hash, sha256_arr(spec.rp_id.as_bytes()));
488        assert_eq!(reg.sign_count, expected_count);
489        assert_eq!(reg.aaguid, expected_aaguid);
490
491        let assertion = build_assertion(spec, seed);
492        assert_eq!(assertion.sign_count, expected_count.saturating_add(1));
493        assert_eq!(assertion.rp_id_hash, reg.rp_id_hash);
494    }
495
496    #[test]
497    fn mock_signature_hashes_seed_context_and_body() {
498        let seed = [5_u8; 32];
499        let body = b"auth-data-and-client-data";
500        let context = b"assertion";
501        let mut h = Sha256::new();
502        h.update(seed);
503        h.update(context);
504        h.update(body);
505
506        assert_eq!(mock_signature(&seed, body, context), h.finalize().to_vec());
507    }
508
509    #[test]
510    fn base64url_matches_known_no_padding_vectors() {
511        let cases: &[(&[u8], &str)] = &[
512            (b"", ""),
513            (b"f", "Zg"),
514            (b"fo", "Zm8"),
515            (b"foo", "Zm9v"),
516            (b"foob", "Zm9vYg"),
517            (b"fooba", "Zm9vYmE"),
518            (b"foobar", "Zm9vYmFy"),
519            (&[0xfb, 0xff], "-_8"),
520        ];
521
522        for (input, expected) in cases {
523            assert_eq!(base64url(input), *expected);
524        }
525    }
526
527    #[test]
528    fn sha256_arr_matches_known_digest() {
529        assert_eq!(
530            sha256_arr(b"abc"),
531            [
532                0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
533                0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
534                0xf2, 0x00, 0x15, 0xad,
535            ]
536        );
537    }
538
539    #[test]
540    fn stable_bytes_keeps_legacy_short_length_encoding() {
541        let spec = WebAuthnSpec::packed("example.com", b"short-challenge");
542        let bytes = spec.stable_bytes();
543        let marker = b"challenge\x1f";
544        let at = bytes
545            .windows(marker.len())
546            .position(|window| window == marker)
547            .expect("challenge marker present");
548        let len_offset = at + marker.len();
549        assert_eq!(&bytes[len_offset..len_offset + 2], &[0, 15]);
550    }
551
552    #[test]
553    fn stable_bytes_long_challenge_uses_extended_length_prefix() {
554        let long = vec![0xAB; 70_000];
555        let spec = WebAuthnSpec::packed("example.com", &long);
556        let bytes = spec.stable_bytes();
557        let marker = b"challenge\x1f";
558        let at = bytes
559            .windows(marker.len())
560            .position(|window| window == marker)
561            .expect("challenge marker present");
562        let len_offset = at + marker.len();
563        assert_eq!(&bytes[len_offset..len_offset + 2], &[0xFF, 0xFF]);
564        assert_eq!(
565            &bytes[len_offset + 2..len_offset + 6],
566            &(70_000u32).to_be_bytes()
567        );
568    }
569
570    fn assert_contains_bytes(haystack: &[u8], needle: &[u8]) {
571        assert!(
572            haystack
573                .windows(needle.len())
574                .any(|window| window == needle),
575            "expected bytes to contain {:?}",
576            String::from_utf8_lossy(needle)
577        );
578    }
579
580    fn value_by_integer_key(entries: &[(Value, Value)], key: i64) -> Option<&Value> {
581        entries
582            .iter()
583            .find_map(|(k, v)| (*k == Value::Integer(key.into())).then_some(v))
584    }
585
586    fn bytes_by_integer_key(entries: &[(Value, Value)], key: i64) -> Option<&[u8]> {
587        match value_by_integer_key(entries, key)? {
588            Value::Bytes(bytes) => Some(bytes.as_slice()),
589            _ => None,
590        }
591    }
592
593    #[test]
594    fn assertion_fixture_fields_are_deterministic_and_consistent() {
595        let fx = Factory::deterministic_from_str("webauthn-assertion-fields");
596        let spec = WebAuthnSpec::packed("example.com", b"challenge-assertion");
597
598        let a = fx.webauthn_assertion("alice", spec.clone());
599        let b = fx.webauthn_assertion("alice", spec.clone());
600
601        assert_eq!(a.client_data_json, b.client_data_json);
602        assert_eq!(a.authenticator_data, b.authenticator_data);
603        assert_eq!(a.signature, b.signature);
604        assert_eq!(a.rp_id_hash, b.rp_id_hash);
605
606        // rp_id_hash is sha256 of rp_id
607        assert_eq!(a.rp_id_hash, sha256_arr(spec.rp_id.as_bytes()));
608
609        // The first 32 bytes of authenticator_data is the rp_id_hash
610        assert_eq!(&a.authenticator_data[..32], &a.rp_id_hash);
611
612        // Assertion clientDataJSON has type "webauthn.get"
613        let parsed: Result<serde_json::Value, _> = serde_json::from_slice(&a.client_data_json);
614        assert!(
615            parsed.is_ok(),
616            "clientDataJSON must parse: {:?}",
617            parsed.as_ref().err()
618        );
619        if let Ok(json) = parsed {
620            assert_eq!(json["type"], "webauthn.get");
621        }
622    }
623
624    #[test]
625    fn self_attestation_registration_uses_self_fmt() {
626        let fx = Factory::deterministic_from_str("webauthn-self-attestation");
627        let mut spec = WebAuthnSpec::packed("example.com", b"challenge-self");
628        spec.attestation_mode = AttestationMode::SelfAttestation;
629
630        let reg = fx.webauthn_registration("alice", spec);
631        let parsed: Result<Value, _> = from_reader(reg.attestation_object.as_slice());
632        assert!(parsed.is_ok(), "attestation_object must parse as CBOR");
633        assert!(
634            matches!(parsed, Ok(Value::Map(_))),
635            "attestation_object must be a CBOR map, got {parsed:?}"
636        );
637
638        if let Ok(Value::Map(entries)) = parsed {
639            let fmt_value = entries
640                .iter()
641                .find_map(|(k, v)| (*k == Value::Text("fmt".to_string())).then_some(v));
642            assert_eq!(fmt_value, Some(&Value::Text("self".to_string())));
643        }
644    }
645
646    #[test]
647    fn packed_and_self_attestation_objects_differ() {
648        let fx = Factory::deterministic_from_str("webauthn-att-mode-diff");
649        let challenge = b"challenge-att-diff";
650
651        let packed_spec = WebAuthnSpec::packed("example.com", challenge);
652        let mut self_spec = packed_spec.clone();
653        self_spec.attestation_mode = AttestationMode::SelfAttestation;
654
655        let packed = fx.webauthn_registration("alice", packed_spec);
656        let self_attest = fx.webauthn_registration("alice", self_spec);
657
658        assert_ne!(
659            packed.attestation_object, self_attest.attestation_object,
660            "registrations with different attestation_mode must produce distinct objects"
661        );
662    }
663
664    #[test]
665    fn distinct_labels_produce_distinct_registration_objects() {
666        let fx = Factory::deterministic_from_str("webauthn-label-uniq");
667        let spec = WebAuthnSpec::packed("example.com", b"challenge-labels");
668
669        let alice = fx.webauthn_registration("alice", spec.clone());
670        let bob = fx.webauthn_registration("bob", spec);
671
672        assert_ne!(
673            alice.attestation_object, bob.attestation_object,
674            "labels are part of the cache identity and seed derivation"
675        );
676        assert_ne!(alice.aaguid, bob.aaguid);
677    }
678
679    #[test]
680    fn distinct_challenges_produce_distinct_assertion_signatures() {
681        let fx = Factory::deterministic_from_str("webauthn-challenge-uniq");
682
683        let a = fx.webauthn_assertion(
684            "alice",
685            WebAuthnSpec::packed("example.com", b"challenge-aaa"),
686        );
687        let b = fx.webauthn_assertion(
688            "alice",
689            WebAuthnSpec::packed("example.com", b"challenge-bbb"),
690        );
691
692        assert_ne!(a.signature, b.signature);
693        assert_ne!(a.client_data_json, b.client_data_json);
694        assert_ne!(a.sign_count, b.sign_count);
695    }
696
697    #[test]
698    fn webauthn_spec_packed_accepts_owned_challenge_vec() {
699        // Compile-time check that the AsRef<[u8]> bound on `challenge`
700        // accepts both borrowed slices and owned Vec<u8>.
701        let owned_challenge: Vec<u8> = vec![1, 2, 3, 4];
702        let spec = WebAuthnSpec::packed("example.com", owned_challenge.clone());
703
704        assert_eq!(spec.challenge, owned_challenge);
705        assert_eq!(spec.attestation_mode, AttestationMode::Packed);
706    }
707
708    #[test]
709    fn webauthn_spec_partial_eq_distinguishes_fields() {
710        let base = WebAuthnSpec::packed("example.com", b"chal");
711        assert_eq!(base, base.clone());
712
713        let mut mode_changed = base.clone();
714        mode_changed.attestation_mode = AttestationMode::SelfAttestation;
715        assert_ne!(base, mode_changed);
716
717        let mut model_changed = base.clone();
718        model_changed.authenticator_model = "OTHER-MODEL".to_string();
719        assert_ne!(base, model_changed);
720
721        let mut credential_changed = base.clone();
722        credential_changed.credential_id = b"different-id".to_vec();
723        assert_ne!(base, credential_changed);
724    }
725}