Skip to main content

mkit_attest/grant/
webauthn.rs

1//! The `webauthn-p256` owner scheme (SPEC-WRITE-GRANTS §4, §4.3, §4.4):
2//! the blob codec, the deployment's relying parties, the strict
3//! `clientDataJSON` check and the assertion verifier.
4//!
5//! A client builds the blob from what its authenticator returned: the
6//! credential public key as raw `x ‖ y`, `authenticatorData`, the exact
7//! `clientDataJSON` bytes whose `challenge` is [`webauthn_challenge`] of the
8//! statement, and the DER signature turned into low-S raw `r ‖ s` with
9//! [`crate::eth::p256_der_to_low_s_raw`]. Then [`WebAuthnAssertion::encode`].
10//!
11//! This module is independent of the DSSE wrapping helper in
12//! [`crate::webauthn`] (SPEC-EXTERNAL-SIGNER §14), whose rules differ.
13
14use std::collections::BTreeSet;
15use std::fmt;
16
17use base64::Engine as _;
18use base64::engine::general_purpose::URL_SAFE_NO_PAD;
19use p256::ecdsa::signature::Verifier as _;
20use p256::ecdsa::{Signature, VerifyingKey};
21use serde::de::{self, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor};
22use sha2::{Digest, Sha256};
23
24use super::GrantError;
25use super::config::is_loopback_origin;
26use crate::eth::{self, Address, EthError};
27
28/// Shortest `authenticatorData`: rpIdHash (32), flags (1), signCount (4).
29const MIN_AUTHENTICATOR_DATA: usize = 37;
30/// The user-present flag bit of `authenticatorData[32]`.
31const FLAG_USER_PRESENT: u8 = 0x01;
32/// The only accepted `clientDataJSON.type`.
33const CLIENT_DATA_TYPE_GET: &str = "webauthn.get";
34/// Longest relying-party id: a DNS name.
35const MAX_RP_ID_LEN: usize = 253;
36/// Deepest accepted `clientDataJSON` nesting (§4.3 rule 2): the top-level
37/// object is depth 1, and each array or object inside adds one. An explicit
38/// limit, independent of (and below) `serde_json`'s own recursion cap.
39pub const MAX_CLIENT_DATA_DEPTH: usize = 64;
40
41// ---- relying parties ---------------------------------------------------
42
43/// A `WebAuthn` relying party a deployment accepts `webauthn-p256`
44/// assertions for (§4.3 rule 4): its id, whose SHA-256 the authenticator
45/// puts in `authenticatorData`, and the origins a `clientDataJSON` from it
46/// may name.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct RelyingParty {
49    id: String,
50    id_hash: [u8; 32],
51    origins: Vec<String>,
52}
53
54impl RelyingParty {
55    /// A relying party with `id` and its allowed `origins`.
56    ///
57    /// `id` is a lowercase DNS name: dot-separated labels of `a-z`, `0-9` and
58    /// `-`, each 1 to 63 bytes without a leading or trailing `-`, at most 253
59    /// bytes in all, and a last label that is not all digits (a `WebAuthn`
60    /// relying-party id is a domain, never an IP address). Each origin is a
61    /// non-empty string of printable ASCII (`0x21..=0x7E`), compared byte for
62    /// byte with `clientDataJSON.origin`; there is at least one and no
63    /// duplicate. Origins are not otherwise parsed, so native-app origins
64    /// (for example `android:apk-key-hash:…`) can be listed.
65    ///
66    /// # Errors
67    /// `RelyingParty` for any rule above.
68    pub fn new<'a>(
69        id: &str,
70        origins: impl IntoIterator<Item = &'a str>,
71    ) -> Result<Self, GrantError> {
72        if !is_dns_name(id) {
73            return Err(GrantError::RelyingParty);
74        }
75        let mut list: Vec<String> = Vec::new();
76        for origin in origins {
77            let printable =
78                !origin.is_empty() && origin.bytes().all(|b| (0x21..=0x7E).contains(&b));
79            if !printable || list.iter().any(|o| o == origin) {
80                return Err(GrantError::RelyingParty);
81            }
82            list.push(origin.to_owned());
83        }
84        if list.is_empty() {
85            return Err(GrantError::RelyingParty);
86        }
87        Ok(Self {
88            id: id.to_owned(),
89            id_hash: Sha256::digest(id.as_bytes()).into(),
90            origins: list,
91        })
92    }
93
94    /// The relying-party id.
95    #[must_use]
96    pub fn id(&self) -> &str {
97        &self.id
98    }
99
100    /// The origins allowed for this relying party.
101    #[must_use]
102    pub fn origins(&self) -> &[String] {
103        &self.origins
104    }
105
106    /// Whether this relying party is a loopback one: id `localhost` or
107    /// `*.localhost`, or a loopback origin ([`is_loopback_origin`]).
108    pub(crate) fn is_loopback(&self) -> bool {
109        self.id == "localhost"
110            || self.id.ends_with(".localhost")
111            || self.origins.iter().any(|o| is_loopback_origin(o))
112    }
113
114    /// Whether `origin` is configured for this relying party.
115    pub(crate) fn allows(&self, origin: &str) -> bool {
116        self.origins.iter().any(|o| o == origin)
117    }
118}
119
120fn is_dns_name(id: &str) -> bool {
121    if id.is_empty() || id.len() > MAX_RP_ID_LEN {
122        return false;
123    }
124    let labels_ok = id.split('.').all(|label| {
125        !label.is_empty()
126            && label.len() <= 63
127            && !label.starts_with('-')
128            && !label.ends_with('-')
129            && label
130                .bytes()
131                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
132    });
133    let last_is_number = id
134        .rsplit('.')
135        .next()
136        .is_some_and(|l| l.bytes().all(|b| b.is_ascii_digit()));
137    labels_ok && !last_is_number
138}
139
140// ---- blob codec ----------------------------------------------------------
141
142/// The four fields of a `webauthn-p256` blob (§4), each encoded as
143/// `[u32 LE length][bytes]` (SPEC-CONVENTIONS §3), in this order, with
144/// nothing after the fourth.
145///
146/// This is plain data: parsing checks the framing and the two fixed
147/// lengths only. [`super::verify_owner_signature`] does the §4.3 checks.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct WebAuthnAssertion {
150    /// The credential public key: `x ‖ y`, each 32 bytes big-endian.
151    pub public_key: [u8; 64],
152    /// The authenticator's `authenticatorData`, as returned.
153    pub authenticator_data: Vec<u8>,
154    /// The exact `clientDataJSON` bytes the authenticator signed over.
155    pub client_data_json: Vec<u8>,
156    /// The signature as raw low-S `r ‖ s` (§4.4); see
157    /// [`crate::eth::p256_der_to_low_s_raw`].
158    pub signature: [u8; 64],
159}
160
161impl WebAuthnAssertion {
162    /// Split a blob into its four fields.
163    ///
164    /// # Errors
165    /// `WebAuthnBlob` for a truncated length or field, bytes after the
166    /// fourth field, or a public key or signature field that is not exactly
167    /// 64 bytes.
168    pub fn parse(blob: &[u8]) -> Result<Self, GrantError> {
169        let mut rest = blob;
170        let public_key = take_field(&mut rest)?;
171        let authenticator_data = take_field(&mut rest)?;
172        let client_data_json = take_field(&mut rest)?;
173        let signature = take_field(&mut rest)?;
174        if !rest.is_empty() {
175            return Err(GrantError::WebAuthnBlob);
176        }
177        Ok(Self {
178            public_key: public_key
179                .try_into()
180                .map_err(|_| GrantError::WebAuthnBlob)?,
181            authenticator_data: authenticator_data.to_vec(),
182            client_data_json: client_data_json.to_vec(),
183            signature: signature.try_into().map_err(|_| GrantError::WebAuthnBlob)?,
184        })
185    }
186
187    /// The blob bytes; `parse(encode(a)) == a`.
188    ///
189    /// # Errors
190    /// `WebAuthnBlob` if a field is longer than `u32::MAX` bytes.
191    pub fn encode(&self) -> Result<Vec<u8>, GrantError> {
192        let fields: [&[u8]; 4] = [
193            &self.public_key,
194            &self.authenticator_data,
195            &self.client_data_json,
196            &self.signature,
197        ];
198        let mut out = Vec::with_capacity(16 + fields.iter().map(|f| f.len()).sum::<usize>());
199        for field in fields {
200            let len = u32::try_from(field.len()).map_err(|_| GrantError::WebAuthnBlob)?;
201            out.extend_from_slice(&len.to_le_bytes());
202            out.extend_from_slice(field);
203        }
204        Ok(out)
205    }
206}
207
208fn take_field<'a>(rest: &mut &'a [u8]) -> Result<&'a [u8], GrantError> {
209    let (len, tail) = rest
210        .split_first_chunk::<4>()
211        .ok_or(GrantError::WebAuthnBlob)?;
212    let len = usize::try_from(u32::from_le_bytes(*len)).map_err(|_| GrantError::WebAuthnBlob)?;
213    if tail.len() < len {
214        return Err(GrantError::WebAuthnBlob);
215    }
216    let (field, tail) = tail.split_at(len);
217    *rest = tail;
218    Ok(field)
219}
220
221/// The `challenge` a `webauthn-p256` assertion over `statement` carries
222/// (§4.3 rule 2): the unpadded base64url of the statement's 32-byte
223/// BLAKE3, 43 characters.
224#[must_use]
225pub fn webauthn_challenge(statement: &[u8]) -> String {
226    URL_SAFE_NO_PAD.encode(mkit_core::hash::hash(statement))
227}
228
229// ---- strict clientDataJSON -------------------------------------------------
230
231/// What the §4.3 checks need to know about one member value.
232#[derive(Clone, Debug, PartialEq, Eq)]
233enum Member {
234    /// A string, after JSON unescaping.
235    Str(String),
236    /// The literal `false`.
237    False,
238    /// Anything else.
239    Other,
240}
241
242/// The top-level members §4.3 rule 2 and rule 4 read.
243#[derive(Debug, Default)]
244struct ClientData {
245    ty: Option<Member>,
246    challenge: Option<Member>,
247    origin: Option<Member>,
248    cross_origin: Option<Member>,
249    top_origin: bool,
250}
251
252/// Walk one JSON value at nesting `depth` (the top-level object is 1),
253/// rejecting a repeated member name in any object, an array or object
254/// deeper than [`MAX_CLIENT_DATA_DEPTH`], and a non-finite number.
255#[derive(Clone, Copy)]
256struct Walk {
257    depth: usize,
258}
259
260impl Walk {
261    fn enter<E: de::Error>(self) -> Result<Self, E> {
262        if self.depth > MAX_CLIENT_DATA_DEPTH {
263            return Err(E::custom("nesting too deep"));
264        }
265        Ok(Self {
266            depth: self.depth + 1,
267        })
268    }
269}
270
271impl<'de> DeserializeSeed<'de> for Walk {
272    type Value = Member;
273
274    fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<Member, D::Error> {
275        d.deserialize_any(self)
276    }
277}
278
279impl<'de> Visitor<'de> for Walk {
280    type Value = Member;
281
282    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        f.write_str("a JSON value")
284    }
285
286    fn visit_bool<E>(self, v: bool) -> Result<Member, E> {
287        Ok(if v { Member::Other } else { Member::False })
288    }
289
290    fn visit_i64<E>(self, _: i64) -> Result<Member, E> {
291        Ok(Member::Other)
292    }
293
294    fn visit_u64<E>(self, _: u64) -> Result<Member, E> {
295        Ok(Member::Other)
296    }
297
298    fn visit_f64<E: de::Error>(self, v: f64) -> Result<Member, E> {
299        // serde_json already refuses a number outside the `f64` range; the
300        // rule is §4.3's, so it is stated here too.
301        if v.is_finite() {
302            Ok(Member::Other)
303        } else {
304            Err(E::custom("non-finite number"))
305        }
306    }
307
308    fn visit_str<E>(self, v: &str) -> Result<Member, E> {
309        Ok(Member::Str(v.to_owned()))
310    }
311
312    fn visit_unit<E>(self) -> Result<Member, E> {
313        Ok(Member::Other)
314    }
315
316    fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Member, A::Error> {
317        let inner = self.enter()?;
318        while seq.next_element_seed(inner)?.is_some() {}
319        Ok(Member::Other)
320    }
321
322    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Member, A::Error> {
323        walk_object(&mut map, self.enter()?, |_, _| {})?;
324        Ok(Member::Other)
325    }
326}
327
328/// Walk one object's members (their values walked with `inner`), calling
329/// `each` with every (decoded) name and value; a repeated name is an error.
330fn walk_object<'de, A: MapAccess<'de>>(
331    map: &mut A,
332    inner: Walk,
333    mut each: impl FnMut(&str, Member),
334) -> Result<(), A::Error> {
335    let mut seen = BTreeSet::new();
336    while let Some(name) = map.next_key::<String>()? {
337        let value = map.next_value_seed(inner)?;
338        each(&name, value);
339        if !seen.insert(name) {
340            return Err(de::Error::custom("duplicate member name"));
341        }
342    }
343    Ok(())
344}
345
346/// The top level: an object whose §4.3 members are recorded.
347struct TopLevel;
348
349impl<'de> DeserializeSeed<'de> for TopLevel {
350    type Value = ClientData;
351
352    fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<ClientData, D::Error> {
353        d.deserialize_any(TopLevel)
354    }
355}
356
357impl<'de> Visitor<'de> for TopLevel {
358    type Value = ClientData;
359
360    fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        f.write_str("a JSON object")
362    }
363
364    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<ClientData, A::Error> {
365        let mut out = ClientData::default();
366        let inner = Walk { depth: 1 }.enter()?;
367        walk_object(&mut map, inner, |name, value| match name {
368            "type" => out.ty = Some(value),
369            "challenge" => out.challenge = Some(value),
370            "origin" => out.origin = Some(value),
371            "crossOrigin" => out.cross_origin = Some(value),
372            "topOrigin" => out.top_origin = true,
373            _ => {}
374        })?;
375        Ok(out)
376    }
377}
378
379/// §4.3 rule 2's syntax: an RFC 8259 object with no duplicate member name
380/// at any depth (names compared after unescaping), nesting at most
381/// [`MAX_CLIENT_DATA_DEPTH`] deep, every number finite as an IEEE 754
382/// binary64 value, valid UTF-8 and nothing but whitespace after it.
383/// `serde_json` also refuses unpaired surrogate escapes, raw control
384/// characters, `NaN`, comments and trailing commas.
385fn parse_client_data(bytes: &[u8]) -> Result<ClientData, GrantError> {
386    let mut de = serde_json::Deserializer::from_slice(bytes);
387    let data = TopLevel
388        .deserialize(&mut de)
389        .map_err(|_| GrantError::ClientData)?;
390    de.end().map_err(|_| GrantError::ClientData)?;
391    Ok(data)
392}
393
394// ---- verification ------------------------------------------------------------
395
396/// The relying party and origin an accepted assertion was bound to.
397#[derive(Clone, Debug, PartialEq, Eq)]
398pub(crate) struct WebAuthnBinding {
399    pub(crate) rp_id: String,
400    pub(crate) origin: String,
401}
402
403/// §4, §4.1, §4.3 and §4.4 for a `webauthn-p256` blob over `statement`,
404/// with the owner `namespace` (a `0x` address). Every failure maps to one
405/// code (§11), but the order is fixed so that the golden reject vectors,
406/// and `scripts/golden/grants_ref.py`, name the same first failure:
407/// framing (`WebAuthnBlob`); scalars and low-S (`SignatureScalar`,
408/// `HighS`); the key is a P-256 point (`InvalidOwnerKey`) whose address is
409/// the namespace (`OwnerMismatch`); `authenticatorData` length and the UP
410/// flag (`AuthenticatorData`, `UserNotPresent`); the relying party
411/// (`RelyingPartyMismatch`); `clientDataJSON` syntax (`ClientData`),
412/// `type`, `challenge`, `crossOrigin`, `topOrigin`, then `origin` for that
413/// relying party (`OriginNotAllowed`); and last the signature over
414/// `authenticatorData ‖ SHA-256(clientDataJSON)` as received
415/// (`BadSignature`).
416pub(crate) fn verify_webauthn(
417    relying_parties: &[RelyingParty],
418    statement: &[u8],
419    blob: &[u8],
420    namespace: &Address,
421) -> Result<WebAuthnBinding, GrantError> {
422    let a = WebAuthnAssertion::parse(blob)?;
423    eth::p256_check_raw_low_s(&a.signature).map_err(|e| match e {
424        EthError::HighS => GrantError::HighS,
425        _ => GrantError::SignatureScalar,
426    })?;
427    let address = eth::address_p256(&a.public_key).map_err(|_| GrantError::InvalidOwnerKey)?;
428    if address != *namespace {
429        return Err(GrantError::OwnerMismatch);
430    }
431
432    let auth = &a.authenticator_data;
433    if auth.len() < MIN_AUTHENTICATOR_DATA {
434        return Err(GrantError::AuthenticatorData);
435    }
436    if auth[32] & FLAG_USER_PRESENT == 0 {
437        return Err(GrantError::UserNotPresent);
438    }
439    let rp = relying_parties
440        .iter()
441        .find(|rp| rp.id_hash[..] == auth[..32])
442        .ok_or(GrantError::RelyingPartyMismatch)?;
443
444    let data = parse_client_data(&a.client_data_json)?;
445    if data.ty != Some(Member::Str(CLIENT_DATA_TYPE_GET.to_owned())) {
446        return Err(GrantError::ClientDataType);
447    }
448    if data.challenge != Some(Member::Str(webauthn_challenge(statement))) {
449        return Err(GrantError::Challenge);
450    }
451    if !matches!(data.cross_origin, None | Some(Member::False)) {
452        return Err(GrantError::CrossOrigin);
453    }
454    if data.top_origin {
455        return Err(GrantError::TopOrigin);
456    }
457    let origin = match data.origin {
458        Some(Member::Str(o)) if rp.allows(&o) => o,
459        _ => return Err(GrantError::OriginNotAllowed),
460    };
461
462    let mut sec1 = [0u8; 65];
463    sec1[0] = 0x04;
464    sec1[1..].copy_from_slice(&a.public_key);
465    let key = VerifyingKey::from_sec1_bytes(&sec1).map_err(|_| GrantError::InvalidOwnerKey)?;
466    let signature = Signature::from_slice(&a.signature).map_err(|_| GrantError::SignatureScalar)?;
467    let mut signed = Vec::with_capacity(auth.len() + 32);
468    signed.extend_from_slice(auth);
469    signed.extend_from_slice(&Sha256::digest(&a.client_data_json));
470    key.verify(&signed, &signature)
471        .map_err(|_| GrantError::BadSignature)?;
472    Ok(WebAuthnBinding {
473        rp_id: rp.id.clone(),
474        origin,
475    })
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn webauthn_relying_party_rules() {
484        let rp = RelyingParty::new("example.com", ["https://example.com"]).unwrap();
485        assert_eq!(rp.id(), "example.com");
486        assert_eq!(rp.origins(), ["https://example.com"]);
487        assert_eq!(rp.id_hash, <[u8; 32]>::from(Sha256::digest(b"example.com")));
488        assert!(
489            RelyingParty::new("a-1.b2.example", ["android:apk-key-hash:x", "https://a"]).is_ok()
490        );
491        assert!(RelyingParty::new("localhost", ["http://localhost:8080"]).is_ok());
492        let long_ok = format!(
493            "{}.{}.{}.{}",
494            "a".repeat(63),
495            "b".repeat(63),
496            "c".repeat(63),
497            "d".repeat(61)
498        );
499        assert_eq!(long_ok.len(), 253);
500        assert!(RelyingParty::new(&long_ok, ["https://x"]).is_ok());
501        for bad in [
502            "",
503            "Example.com",
504            "example..com",
505            ".example.com",
506            "example.com.",
507            "-a.example",
508            "a-.example",
509            "ex_ample.com",
510            "127.0.0.1",
511            "example.123",
512            &format!("{long_ok}x"),
513            &format!("{}.com", "a".repeat(64)),
514        ] {
515            assert_eq!(
516                RelyingParty::new(bad, ["https://example.com"]),
517                Err(GrantError::RelyingParty),
518                "{bad}"
519            );
520        }
521        for origins in [
522            &[][..],
523            &[""][..],
524            &["https://a b"][..],
525            &["https://é"][..],
526            &["https://a", "https://a"][..],
527        ] {
528            assert_eq!(
529                RelyingParty::new("example.com", origins.iter().copied()),
530                Err(GrantError::RelyingParty),
531                "{origins:?}"
532            );
533        }
534    }
535
536    #[test]
537    fn webauthn_relying_party_loopback() {
538        for (id, origin, loopback) in [
539            ("localhost", "https://example.com", true),
540            ("dev.localhost", "https://example.com", true),
541            ("example.com", "http://127.0.0.1:8080", true),
542            ("example.com", "http://[::1]", true),
543            ("example.com", "https://example.com", false),
544            ("localhost.example", "https://localhost.example", false),
545        ] {
546            assert_eq!(
547                RelyingParty::new(id, [origin]).unwrap().is_loopback(),
548                loopback,
549                "{id} {origin}"
550            );
551        }
552    }
553
554    /// 2^1024 − 2^970 − 1: the largest integer that rounds to `f64::MAX`.
555    const MAX_ROUNDING_INT: &str = "179769313486231580793728971405303415079934132710037826936173778980444968292764750946649017977587207096330286416692887910946555547851940402630657488671505820681908902000708383676273854845817711531764475730270069855571366959622842914819860834936475292719074168444365510704342711559699508093042880177904174497791";
556    /// 2^1024 − 2^970: halfway to 2^1024, so it rounds (to even) to infinity.
557    const TIE_INT: &str = "179769313486231580793728971405303415079934132710037826936173778980444968292764750946649017977587207096330286416692887910946555547851940402630657488671505820681908902000708383676273854845817711531764475730270069855571366959622842914819860834936475292719074168444365510704342711559699508093042880177904174497792";
558
559    #[test]
560    fn webauthn_client_data_strict_json() {
561        let ok = |s: &str| parse_client_data(s.as_bytes());
562        let d = ok(r#" {"type":"webauthn.get","challenge":"c","origin":"o","crossOrigin":false,"x":[1,{"a":null}]} "#)
563            .unwrap();
564        assert_eq!(d.ty, Some(Member::Str("webauthn.get".into())));
565        assert_eq!(d.challenge, Some(Member::Str("c".into())));
566        assert_eq!(d.origin, Some(Member::Str("o".into())));
567        assert_eq!(d.cross_origin, Some(Member::False));
568        assert!(!d.top_origin);
569        // Names and values are compared after unescaping.
570        let d = ok(r#"{"type":"webauthn.get","topOrigin":1}"#).unwrap();
571        assert_eq!(d.ty, Some(Member::Str("webauthn.get".into())));
572        assert!(d.top_origin);
573        assert_eq!(
574            ok(r#"{"crossOrigin":"false"}"#).unwrap().cross_origin,
575            Some(Member::Str("false".into()))
576        );
577        assert_eq!(
578            ok(r#"{"crossOrigin":true}"#).unwrap().cross_origin,
579            Some(Member::Other)
580        );
581        for bad in [
582            r#"{"type":"a","type":"a"}"#,
583            r#"{"type":"a","type":"a"}"#,
584            r#"{"x":{"a":1,"a":1}}"#,
585            r#"{"x":[{"a":1},{"b":[{"c":1,"c":2}]}]}"#,
586            r#"{"x":"\ud800"}"#,
587            r#"{"\udc00":1}"#,
588            r#"{"x":1,}"#,
589            r#"{"x":NaN}"#,
590            r#"{"x":1} x"#,
591            r#"{"x":1}{}"#,
592            "{\"x\":\"a\u{1}\"}",
593            "\u{feff}{}",
594            r#"["type"]"#,
595            r#""webauthn.get""#,
596            "",
597            "{",
598        ] {
599            assert_eq!(
600                parse_client_data(bad.as_bytes()).unwrap_err(),
601                GrantError::ClientData,
602                "{bad}"
603            );
604        }
605        assert_eq!(
606            parse_client_data(b"{\"x\":\"\xff\"}").unwrap_err(),
607            GrantError::ClientData
608        );
609        // Nesting: the top-level object is depth 1; depth 64 is accepted,
610        // 65 is not, whatever mix of arrays and objects gets there.
611        let nest = |depth: usize, open: &str, close: &str| {
612            format!(
613                "{{\"x\":{}1{}}}",
614                open.repeat(depth - 1),
615                close.repeat(depth - 1)
616            )
617        };
618        assert!(parse_client_data(nest(MAX_CLIENT_DATA_DEPTH, "[", "]").as_bytes()).is_ok());
619        assert!(parse_client_data(nest(MAX_CLIENT_DATA_DEPTH, "{\"a\":", "}").as_bytes()).is_ok());
620        assert_eq!(
621            parse_client_data(nest(MAX_CLIENT_DATA_DEPTH + 1, "[", "]").as_bytes()).unwrap_err(),
622            GrantError::ClientData
623        );
624        assert_eq!(
625            parse_client_data(nest(MAX_CLIENT_DATA_DEPTH + 1, "{\"a\":", "}").as_bytes())
626                .unwrap_err(),
627            GrantError::ClientData
628        );
629        // Numbers: finite binary64 values only.
630        for good in [
631            "1e308",
632            "-1.7976931348623157e308",
633            // Round to f64::MAX (correctly rounded parsing): finite.
634            "1.7976931348623158e308",
635            "-1.7976931348623158e308",
636            MAX_ROUNDING_INT,
637            "1e-400",
638            "123456789012345678901234567890",
639        ] {
640            assert!(
641                parse_client_data(format!("{{\"x\":{good}}}").as_bytes()).is_ok(),
642                "{good}"
643            );
644        }
645        for bad in [
646            "1e400",
647            "-1e400",
648            &format!("1{}", "0".repeat(400)),
649            // Just past the rounding boundary: infinity.
650            "1.7976931348623159e308",
651            "-1.7976931348623159e308",
652            TIE_INT,
653        ] {
654            assert_eq!(
655                parse_client_data(format!("{{\"x\":{bad}}}").as_bytes()).unwrap_err(),
656                GrantError::ClientData,
657                "{bad}"
658            );
659        }
660        // The walk sees every depth, even inside arrays of arrays.
661        let deep = format!("{}{{\"a\":1,\"a\":2}}{}", "[".repeat(50), "]".repeat(50));
662        assert!(parse_client_data(format!("{{\"x\":{deep}}}").as_bytes()).is_err());
663    }
664
665    #[test]
666    fn webauthn_blob_framing() {
667        let a = WebAuthnAssertion {
668            public_key: [1; 64],
669            authenticator_data: vec![2; 37],
670            client_data_json: b"{}".to_vec(),
671            signature: [3; 64],
672        };
673        let blob = a.encode().unwrap();
674        assert_eq!(blob.len(), 16 + 64 + 37 + 2 + 64);
675        assert_eq!(&blob[..4], &64u32.to_le_bytes());
676        assert_eq!(WebAuthnAssertion::parse(&blob).unwrap(), a);
677        let mut trailing = blob.clone();
678        trailing.push(0);
679        assert_eq!(
680            WebAuthnAssertion::parse(&trailing),
681            Err(GrantError::WebAuthnBlob)
682        );
683        for cut in 0..blob.len() {
684            assert_eq!(
685                WebAuthnAssertion::parse(&blob[..cut]),
686                Err(GrantError::WebAuthnBlob),
687                "{cut}"
688            );
689        }
690        let mut huge = blob.clone();
691        huge[68..72].copy_from_slice(&u32::MAX.to_le_bytes());
692        assert_eq!(
693            WebAuthnAssertion::parse(&huge),
694            Err(GrantError::WebAuthnBlob)
695        );
696    }
697
698    #[test]
699    fn webauthn_challenge_is_43_base64url_chars() {
700        let c = webauthn_challenge(b"statement");
701        assert_eq!(c.len(), 43);
702        assert_eq!(
703            URL_SAFE_NO_PAD.decode(&c).unwrap(),
704            blake3::hash(b"statement").as_bytes()
705        );
706    }
707
708    /// Proptest case count: `PROPTEST_CASES` when set (for deeper local runs),
709    /// else `default` (explicit `with_cases` would otherwise ignore the env).
710    fn cases(default: u32) -> u32 {
711        std::env::var("PROPTEST_CASES")
712            .ok()
713            .and_then(|v| v.parse().ok())
714            .unwrap_or(default)
715    }
716
717    fn json_value() -> impl proptest::strategy::Strategy<Value = serde_json::Value> {
718        use proptest::prelude::*;
719        use serde_json::Value;
720        let leaf = prop_oneof![
721            Just(Value::Null),
722            any::<bool>().prop_map(Value::Bool),
723            any::<i64>().prop_map(Value::from),
724            (-1e300f64..1e300).prop_map(Value::from),
725            "\\PC{0,8}".prop_map(Value::String),
726        ];
727        leaf.prop_recursive(4, 48, 6, |inner| {
728            prop_oneof![
729                prop::collection::vec(inner.clone(), 0..5).prop_map(Value::Array),
730                prop::collection::btree_map("[a-z\\u{e9}\"\\\\]{0,4}", inner, 0..5)
731                    .prop_map(|m| Value::Object(m.into_iter().collect())),
732            ]
733        })
734    }
735
736    proptest::proptest! {
737        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(cases(256)))]
738
739        /// Differential against `serde_json::Value`: every duplicate-free
740        /// object serde_json writes is accepted; the same object with one
741        /// member repeated is rejected; and a one-byte mutation the strict
742        /// walk accepts is valid JSON to serde_json too.
743        #[test]
744        fn webauthn_client_data_matches_serde_json(
745            members in proptest::collection::btree_map("[a-z]{0,3}", json_value(), 1..5),
746            pos in proptest::prelude::any::<usize>(),
747            byte in proptest::prelude::any::<u8>(),
748        ) {
749            let object = serde_json::Value::Object(members.clone().into_iter().collect());
750            let text = serde_json::to_vec(&object).unwrap();
751            proptest::prop_assert!(parse_client_data(&text).is_ok());
752            let (name, value) = members.iter().next().unwrap();
753            let member = format!("{}:{}", serde_json::to_string(name).unwrap(), value);
754            let duplicated = format!("{{{member},{}", &String::from_utf8(text.clone()).unwrap()[1..]);
755            proptest::prop_assert_eq!(
756                parse_client_data(duplicated.as_bytes()).unwrap_err(),
757                GrantError::ClientData
758            );
759            let mut mutated = text;
760            let i = pos % mutated.len();
761            mutated[i] = byte;
762            if parse_client_data(&mutated).is_ok() {
763                proptest::prop_assert!(serde_json::from_slice::<serde_json::Value>(&mutated).is_ok());
764            }
765        }
766    }
767}