1#![forbid(unsafe_code)]
2
3use ciborium::{ser::into_writer, value::Value};
9use serde_json::json;
10use sha2::{Digest, Sha256};
11use uselesskey_core::Factory;
12
13pub 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; if attested.is_some() {
234 flags |= 0x40; }
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 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())), (Value::Integer(3.into()), Value::Integer((-7).into())), (Value::Integer((-1).into()), Value::Integer(1.into())), (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 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(®.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!(®[..32], &rp_id_hash);
429 assert_eq!(reg[32], 0x41);
430 assert_eq!(®[33..37], &sign_count.to_be_bytes());
431 assert_eq!(®[37..53], &aaguid);
432 assert_eq!(u16::from_be_bytes(reg[53..55].try_into().unwrap()), 4);
433 assert_eq!(®[55..59], credential_id);
434 assert_eq!(®[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 assert_eq!(a.rp_id_hash, sha256_arr(spec.rp_id.as_bytes()));
608
609 assert_eq!(&a.authenticator_data[..32], &a.rp_id_hash);
611
612 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 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}