webauthn_rp

WebAuthn Level 3 RP library.
git clone https://git.philomathiclife.com/repos/webauthn_rp
Log | Files | Refs | README

ser_relaxed.rs (41037B)


      1 extern crate alloc;
      2 #[cfg(doc)]
      3 use super::{Challenge, LimitedVerificationParser};
      4 use super::{
      5     ClientDataJsonParser, CollectedClientData, Origin, SentChallenge,
      6     ser::{
      7         AuthenticationExtensionsPrfValues, AuthenticationExtensionsPrfValuesVisitor,
      8         PRF_VALUES_FIELDS,
      9     },
     10 };
     11 use alloc::borrow::Cow;
     12 use core::{
     13     fmt::{self, Formatter},
     14     marker::PhantomData,
     15 };
     16 use serde::de::{Deserialize, Deserializer, Error, IgnoredAny, MapAccess, Unexpected, Visitor};
     17 #[cfg(doc)]
     18 use serde_json::de;
     19 /// Category returned by [`SerdeJsonErr::classify`].
     20 pub use serde_json::error::Category;
     21 /// Error returned by [`CollectedClientData::from_client_data_json_relaxed`] or any of the [`Deserialize`]
     22 /// implementations when relying on [`de::Deserializer`] or [`de::StreamDeserializer`].
     23 pub use serde_json::error::Error as SerdeJsonErr;
     24 /// "Relaxed" [`ClientDataJsonParser`].
     25 ///
     26 /// Unlike [`LimitedVerificationParser`] which requires
     27 /// [JSON-compatible serialization of client data](https://www.w3.org/TR/webauthn-3/#collectedclientdata-json-compatible-serialization-of-client-data)
     28 /// to be parsed _exactly_ as required by
     29 /// the [limited verification algorithm](https://www.w3.org/TR/webauthn-3/#clientdatajson-verification),
     30 /// this is a "relaxed" parser.
     31 ///
     32 /// L1 clients predate the JSON-compatible serialization of client data; additionally there are L2 and L3 clients
     33 /// that don't adhere to the JSON-compatible serialization of client data despite being required to. These clients
     34 /// serialize `CollectedClientData` so that it's valid JSON and conforms to the Web IDL `dictionary` and nothing more.
     35 /// Furthermore the spec requires that data be decoded in a way equivalent
     36 /// to [UTF-8 decode](https://encoding.spec.whatwg.org/#utf-8-decode) which both interprets a leading zero
     37 /// width no-breaking space (i.e., U+FEFF) as a byte-order mark (BOM) as well as replaces any sequences of invalid
     38 /// UTF-8 code units with the replacement character (i.e., U+FFFD). That is precisely what this parser does.
     39 ///
     40 /// In particular the parser errors iff any of the following is true:
     41 ///
     42 /// * The payload is not valid JSON _after_ ignoring a leading U+FEFF and replacing any sequences of invalid
     43 ///   UTF-8 code units with U+FFFD.
     44 /// * The JSON does not conform to the Web IDL `dictionary`.
     45 /// * [`type`](https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type) is not `"webauthn.create"`
     46 ///   or `"webauthn.get"` when `REGISTRATION` and `!REGISTRATION` respectively.
     47 /// * [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge) is not a
     48 ///   base64url-encoded [`Challenge`].
     49 /// * Existence of duplicate keys in the root object _including_ keys that otherwise would have been ignored.
     50 pub(super) struct RelaxedClientDataJsonParser<const REGISTRATION: bool>;
     51 impl<const R: bool> ClientDataJsonParser for RelaxedClientDataJsonParser<R> {
     52     type Err = SerdeJsonErr;
     53     fn parse(json: &[u8]) -> Result<CollectedClientData<'_>, Self::Err> {
     54         /// U+FEFF encoded in UTF-8.
     55         const BOM: [u8; 3] = [0xef, 0xbb, 0xbf];
     56         // We avoid first calling `String::from_utf8_lossy` since `CDataJsonHelper` relies on
     57         // [`Deserializer::deserialize_bytes`] instead of [`Deserializer::deserialize_str`] and
     58         // [`Deserializer::deserialize_identifier`]. Additionally [`CollectedClientData::origin`] and
     59         // [`CollectedClientData::top_origin`] are the only fields that need to actually replace invalid
     60         // UTF-8 code units, and this is achieved via the inner `OriginWrapper` type.
     61         serde_json::from_slice::<CDataJsonHelper<'_, R>>(json.split_at_checked(BOM.len()).map_or(
     62             json,
     63             |(bom, rem)| {
     64                 if bom == BOM { rem } else { json }
     65             },
     66         ))
     67         .map(|val| val.0)
     68     }
     69     fn get_sent_challenge(json: &[u8]) -> Result<SentChallenge, Self::Err> {
     70         /// U+FEFF encoded in UTF-8.
     71         const BOM: [u8; 3] = [0xef, 0xbb, 0xbf];
     72         // We avoid first calling `String::from_utf8_lossy` since `Chall` relies on
     73         // [`Deserializer::deserialize_bytes`] instead of [`Deserializer::deserialize_identifier`].
     74         serde_json::from_slice::<Chall>(json.split_at_checked(BOM.len()).map_or(
     75             json,
     76             |(bom, rem)| {
     77                 if bom == BOM { rem } else { json }
     78             },
     79         ))
     80         .map(|c| c.0)
     81     }
     82 }
     83 /// Used by [`RelaxedClientDataJsonParser::get_sent_challenge`] to minimally deserialize the JSON.
     84 struct Chall(SentChallenge);
     85 impl<'de> Deserialize<'de> for Chall {
     86     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     87     where
     88         D: Deserializer<'de>,
     89     {
     90         /// `Visitor` for `Chall`.
     91         struct ChallVisitor;
     92         impl<'d> Visitor<'d> for ChallVisitor {
     93             type Value = Chall;
     94             fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
     95                 formatter.write_str("Chall")
     96             }
     97             fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
     98             where
     99                 A: MapAccess<'d>,
    100             {
    101                 /// Fields in `clientDataJSON`.
    102                 enum Field {
    103                     /// `"challenge"`.
    104                     Challenge,
    105                     /// All other fields.
    106                     Other,
    107                 }
    108                 impl<'e> Deserialize<'e> for Field {
    109                     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    110                     where
    111                         D: Deserializer<'e>,
    112                     {
    113                         /// `Visitor` for `Field`.
    114                         struct FieldVisitor;
    115                         impl Visitor<'_> for FieldVisitor {
    116                             type Value = Field;
    117                             fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    118                                 write!(formatter, "'{CHALLENGE}'")
    119                             }
    120                             fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
    121                             where
    122                                 E: Error,
    123                             {
    124                                 if v == b"challenge" {
    125                                     Ok(Field::Challenge)
    126                                 } else {
    127                                     Ok(Field::Other)
    128                                 }
    129                             }
    130                         }
    131                         deserializer.deserialize_bytes(FieldVisitor)
    132                     }
    133                 }
    134                 let mut chall = None;
    135                 while let Some(key) = map.next_key()? {
    136                     match key {
    137                         Field::Challenge => {
    138                             if chall.is_some() {
    139                                 return Err(Error::duplicate_field(CHALLENGE));
    140                             }
    141                             chall = map.next_value().map(Some)?;
    142                         }
    143                         Field::Other => map.next_value::<IgnoredAny>().map(|_| ())?,
    144                     }
    145                 }
    146                 chall
    147                     .ok_or_else(|| Error::missing_field(CHALLENGE))
    148                     .map(Chall)
    149             }
    150         }
    151         /// Fields we care about.
    152         const FIELDS: &[&str; 1] = &[CHALLENGE];
    153         deserializer.deserialize_struct("Chall", FIELDS, ChallVisitor)
    154     }
    155 }
    156 /// "type".
    157 const TYPE: &str = "type";
    158 /// "challenge".
    159 const CHALLENGE: &str = "challenge";
    160 /// "origin".
    161 const ORIGIN: &str = "origin";
    162 /// "crossOrigin".
    163 const CROSS_ORIGIN: &str = "crossOrigin";
    164 /// "topOrigin".
    165 const TOP_ORIGIN: &str = "topOrigin";
    166 /// Fields for `CollectedClientData`.
    167 const FIELDS: &[&str; 5] = &[TYPE, CHALLENGE, ORIGIN, CROSS_ORIGIN, TOP_ORIGIN];
    168 /// Helper for [`RelaxedClientDataJsonParser`].
    169 struct RelaxedHelper<'a, const REGISTRATION: bool>(PhantomData<fn() -> &'a ()>);
    170 impl<'de: 'a, 'a, const R: bool> Visitor<'de> for RelaxedHelper<'a, R> {
    171     type Value = CollectedClientData<'a>;
    172     fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    173         formatter.write_str("CollectedClientData")
    174     }
    175     #[expect(
    176         clippy::too_many_lines,
    177         reason = "don't want to move code to an outer scope"
    178     )]
    179     fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
    180     where
    181         A: MapAccess<'de>,
    182     {
    183         /// "webauthn.create".
    184         const CREATE: &str = "webauthn.create";
    185         /// "webauthn.get".
    186         const GET: &str = "webauthn.get";
    187         /// `CollectedClientData` fields.
    188         enum Field {
    189             /// "type" field.
    190             Type,
    191             /// "challenge" field.
    192             Challenge,
    193             /// "origin" field.
    194             Origin,
    195             /// "crossOrigin" field.
    196             CrossOrigin,
    197             /// "topOrigin" field.
    198             TopOrigin,
    199             /// Unknown field.
    200             Other,
    201         }
    202         impl<'d> Deserialize<'d> for Field {
    203             fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    204             where
    205                 D: Deserializer<'d>,
    206             {
    207                 /// `Visitor` for `Field`.
    208                 struct FieldVisitor;
    209                 impl Visitor<'_> for FieldVisitor {
    210                     type Value = Field;
    211                     fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    212                         write!(
    213                             formatter,
    214                             "'{TYPE}', '{CHALLENGE}', '{ORIGIN}', '{CROSS_ORIGIN}', or '{TOP_ORIGIN}'"
    215                         )
    216                     }
    217                     fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
    218                     where
    219                         E: Error,
    220                     {
    221                         match v {
    222                             b"type" => Ok(Field::Type),
    223                             b"challenge" => Ok(Field::Challenge),
    224                             b"origin" => Ok(Field::Origin),
    225                             b"crossOrigin" => Ok(Field::CrossOrigin),
    226                             b"topOrigin" => Ok(Field::TopOrigin),
    227                             _ => Ok(Field::Other),
    228                         }
    229                     }
    230                 }
    231                 // MUST NOT call `Deserializer::deserialize_identifier` since that will call
    232                 // `FieldVisitor::visit_str` which obviously requires decoding the field as UTF-8 first.
    233                 deserializer.deserialize_bytes(FieldVisitor)
    234             }
    235         }
    236         /// Deserializes the type value.
    237         /// Contains `true` iff the type is `"webauthn.create"`; otherwise the
    238         /// value is `"webauthn.get"`.
    239         struct Type(bool);
    240         impl<'d> Deserialize<'d> for Type {
    241             fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    242             where
    243                 D: Deserializer<'d>,
    244             {
    245                 /// `Visitor` for `Type`.
    246                 struct TypeVisitor;
    247                 impl Visitor<'_> for TypeVisitor {
    248                     type Value = Type;
    249                     fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    250                         write!(formatter, "'{CREATE}' or '{GET}'")
    251                     }
    252                     fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
    253                     where
    254                         E: Error,
    255                     {
    256                         match v {
    257                             b"webauthn.create" => Ok(Type(true)),
    258                             b"webauthn.get" => Ok(Type(false)),
    259                             _ => Err(Error::invalid_value(
    260                                 Unexpected::Bytes(v),
    261                                 &format!("'{CREATE}' or '{GET}'").as_str(),
    262                             )),
    263                         }
    264                     }
    265                 }
    266                 deserializer.deserialize_bytes(TypeVisitor)
    267             }
    268         }
    269         /// `newtype` around `Origin` that implements [`Deserialize`] such that invalid UTF-8 code units are first
    270         /// replaced with the replacement character. We don't do this for `Origin` since we want its public API
    271         /// to forbid invalid UTF-8.
    272         struct OriginWrapper<'d>(Origin<'d>);
    273         impl<'d: 'e, 'e> Deserialize<'d> for OriginWrapper<'e> {
    274             fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    275             where
    276                 D: Deserializer<'d>,
    277             {
    278                 /// `Visitor` for `OriginWrapper`.
    279                 struct OriginWrapperVisitor<'f>(PhantomData<fn() -> &'f ()>);
    280                 impl<'f: 'g, 'g> Visitor<'f> for OriginWrapperVisitor<'g> {
    281                     type Value = OriginWrapper<'g>;
    282                     fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
    283                         formatter.write_str("OriginWrapper")
    284                     }
    285                     fn visit_borrowed_bytes<E>(self, v: &'f [u8]) -> Result<Self::Value, E>
    286                     where
    287                         E: Error,
    288                     {
    289                         Ok(OriginWrapper(Origin(String::from_utf8_lossy(v))))
    290                     }
    291                     fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
    292                     where
    293                         E: Error,
    294                     {
    295                         Ok(OriginWrapper(Origin(Cow::Owned(
    296                             String::from_utf8_lossy(v.as_slice()).into_owned(),
    297                         ))))
    298                     }
    299                     fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
    300                     where
    301                         E: Error,
    302                     {
    303                         self.visit_byte_buf(v.to_owned())
    304                     }
    305                 }
    306                 deserializer.deserialize_bytes(OriginWrapperVisitor(PhantomData))
    307             }
    308         }
    309         let mut typ = false;
    310         let mut chall = None;
    311         let mut orig = None;
    312         let mut cross = None;
    313         let mut top_orig = None;
    314         while let Some(key) = map.next_key()? {
    315             match key {
    316                 Field::Type => {
    317                     if typ {
    318                         return Err(Error::duplicate_field(TYPE));
    319                     }
    320                     typ = map.next_value::<Type>().and_then(|v| {
    321                         if v.0 {
    322                             if R {
    323                                 Ok(true)
    324                             } else {
    325                                 Err(Error::invalid_value(Unexpected::Str(CREATE), &GET))
    326                             }
    327                         } else if R {
    328                             Err(Error::invalid_value(Unexpected::Str(GET), &CREATE))
    329                         } else {
    330                             Ok(true)
    331                         }
    332                     })?;
    333                 }
    334                 Field::Challenge => {
    335                     if chall.is_some() {
    336                         return Err(Error::duplicate_field(CHALLENGE));
    337                     }
    338                     chall = map.next_value().map(Some)?;
    339                 }
    340                 Field::Origin => {
    341                     if orig.is_some() {
    342                         return Err(Error::duplicate_field(ORIGIN));
    343                     }
    344                     orig = map.next_value::<OriginWrapper<'_>>().map(|o| Some(o.0))?;
    345                 }
    346                 Field::CrossOrigin => {
    347                     if cross.is_some() {
    348                         return Err(Error::duplicate_field(CROSS_ORIGIN));
    349                     }
    350                     cross = map.next_value().map(Some)?;
    351                 }
    352                 Field::TopOrigin => {
    353                     if top_orig.is_some() {
    354                         return Err(Error::duplicate_field(TOP_ORIGIN));
    355                     }
    356                     top_orig = map.next_value::<Option<OriginWrapper<'_>>>().map(Some)?;
    357                 }
    358                 // `IgnoredAny` ignores invalid UTF-8 in and only in JSON strings.
    359                 Field::Other => map.next_value::<IgnoredAny>().map(|_| ())?,
    360             }
    361         }
    362         if typ {
    363             chall
    364                 .ok_or_else(|| Error::missing_field(CHALLENGE))
    365                 .and_then(|challenge| {
    366                     orig.ok_or_else(|| Error::missing_field(ORIGIN))
    367                         .map(|origin| CollectedClientData {
    368                             challenge,
    369                             origin,
    370                             cross_origin: cross.flatten().unwrap_or_default(),
    371                             top_origin: top_orig.flatten().map(|o| o.0),
    372                         })
    373                 })
    374         } else {
    375             Err(Error::missing_field(TYPE))
    376         }
    377     }
    378 }
    379 /// `newtype` around [`CollectedClientData`] to avoid implementing [`Deserialize`] for `CollectedClientData`.
    380 struct CDataJsonHelper<'a, const REGISTRATION: bool>(CollectedClientData<'a>);
    381 impl<'de: 'a, 'a, const R: bool> Deserialize<'de> for CDataJsonHelper<'a, R> {
    382     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    383     where
    384         D: Deserializer<'de>,
    385     {
    386         deserializer
    387             .deserialize_struct(
    388                 "CollectedClientData",
    389                 FIELDS,
    390                 RelaxedHelper::<R>(PhantomData),
    391             )
    392             .map(Self)
    393     }
    394 }
    395 /// `newtype` around `AuthenticationExtensionsPrfValues` with a "relaxed" [`Self::deserialize`] implementation.
    396 pub(super) struct AuthenticationExtensionsPrfValuesRelaxed(pub AuthenticationExtensionsPrfValues);
    397 impl<'de> Deserialize<'de> for AuthenticationExtensionsPrfValuesRelaxed {
    398     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    399     where
    400         D: Deserializer<'de>,
    401     {
    402         deserializer
    403             .deserialize_struct(
    404                 "AuthenticationExtensionsPrfValuesRelaxed",
    405                 PRF_VALUES_FIELDS,
    406                 AuthenticationExtensionsPrfValuesVisitor::<true>,
    407             )
    408             .map(Self)
    409     }
    410 }
    411 #[cfg(test)]
    412 mod tests {
    413     use super::{ClientDataJsonParser as _, Cow, RelaxedClientDataJsonParser};
    414     use serde::de::{Error as _, Unexpected};
    415     use serde_json::Error;
    416     #[expect(clippy::unwrap_used, reason = "OK in tests")]
    417     #[expect(clippy::little_endian_bytes, reason = "comments justify correctness")]
    418     #[expect(clippy::too_many_lines, reason = "a lot to test")]
    419     #[test]
    420     fn relaxed_client_data_json() {
    421         // Base case is correct.
    422         let mut input = serde_json::json!({
    423             "challenge": "ABABABABABABABABABABAA",
    424             "type": "webauthn.create",
    425             "origin": "https://example.com",
    426             "crossOrigin": true,
    427             "topOrigin": "https://example.org"
    428         })
    429         .to_string();
    430         assert!(
    431             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()).is_ok_and(|c| {
    432                 c.cross_origin
    433                     && c.challenge.0
    434                     // challenges are sent little-endian
    435                         == u128::from_le_bytes([
    436                             0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0,
    437                         ])
    438                     && matches!(c.origin.0, Cow::Borrowed(o) if o == "https://example.com")
    439                     && c.top_origin.is_some_and(
    440                         |t| matches!(t.0, Cow::Borrowed(o) if o == "https://example.org"),
    441                     )
    442             })
    443         );
    444         // Base case is correct.
    445         input = serde_json::json!({
    446             "challenge": "ABABABABABABABABABABAA",
    447             "type": "webauthn.get",
    448             "origin": "https://example.com",
    449             "crossOrigin": true,
    450             "topOrigin": "https://example.org"
    451         })
    452         .to_string();
    453         assert!(
    454             RelaxedClientDataJsonParser::<false>::parse(input.as_bytes()).is_ok_and(|c| {
    455                 c.cross_origin
    456                     && c.challenge.0
    457                     // challenges are sent little-endian
    458                         == u128::from_le_bytes([
    459                             0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0,
    460                         ])
    461                     && matches!(c.origin.0, Cow::Borrowed(o) if o == "https://example.com")
    462                     && c.top_origin.is_some_and(
    463                         |t| matches!(t.0, Cow::Borrowed(o) if o == "https://example.org"),
    464                     )
    465             })
    466         );
    467         // Unknown keys are allowed.
    468         input = serde_json::json!({
    469             "challenge": "ABABABABABABABABABABAA",
    470             "type": "webauthn.create",
    471             "origin": "https://example.com",
    472             "crossOrigin": true,
    473             "topOrigin": "https://example.org",
    474             "foo": true
    475         })
    476         .to_string();
    477         drop(RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()).unwrap());
    478         // Duplicate keys are forbidden.
    479         let mut input_str = "{
    480             \"challenge\": \"ABABABABABABABABABABAA\",
    481             \"type\": \"webauthn.create\",
    482             \"origin\": \"https://example.com\",
    483             \"crossOrigin\": true,
    484             \"topOrigin\": \"https://example.org\",
    485             \"crossOrigin\": true
    486         }";
    487         let mut err = Error::duplicate_field("crossOrigin")
    488             .to_string()
    489             .into_bytes();
    490         assert_eq!(
    491             RelaxedClientDataJsonParser::<true>::parse(input_str.as_bytes())
    492                 .unwrap_err()
    493                 .to_string()
    494                 .into_bytes()
    495                 .get(..err.len()),
    496             Some(err.as_slice())
    497         );
    498         // `null` `crossOrigin`.
    499         input = serde_json::json!({
    500             "challenge": "ABABABABABABABABABABAA",
    501             "type": "webauthn.create",
    502             "origin": "https://example.com",
    503             "crossOrigin": null,
    504             "topOrigin": "https://example.org"
    505         })
    506         .to_string();
    507         assert!(
    508             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    509                 .is_ok_and(|c| !c.cross_origin)
    510         );
    511         // Missing `crossOrigin`.
    512         input = serde_json::json!({
    513             "challenge": "ABABABABABABABABABABAA",
    514             "type": "webauthn.create",
    515             "origin": "https://example.com",
    516             "topOrigin": "https://example.org"
    517         })
    518         .to_string();
    519         assert!(
    520             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    521                 .is_ok_and(|c| !c.cross_origin)
    522         );
    523         // `null` `topOrigin`.
    524         input = serde_json::json!({
    525             "challenge": "ABABABABABABABABABABAA",
    526             "type": "webauthn.create",
    527             "origin": "https://example.com",
    528             "crossOrigin": true,
    529             "topOrigin": null
    530         })
    531         .to_string();
    532         assert!(
    533             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    534                 .is_ok_and(|c| c.top_origin.is_none())
    535         );
    536         // Missing `topOrigin`.
    537         input = serde_json::json!({
    538             "challenge": "ABABABABABABABABABABAA",
    539             "type": "webauthn.create",
    540             "origin": "https://example.com",
    541             "crossOrigin": true,
    542         })
    543         .to_string();
    544         assert!(
    545             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    546                 .is_ok_and(|c| c.top_origin.is_none())
    547         );
    548         // `null` `challenge`.
    549         err = Error::invalid_type(
    550             Unexpected::Other("null"),
    551             &"base64 encoding of the 16-byte challenge in a URL safe way without padding",
    552         )
    553         .to_string()
    554         .into_bytes();
    555         input = serde_json::json!({
    556             "challenge": null,
    557             "type": "webauthn.create",
    558             "origin": "https://example.com",
    559             "crossOrigin": true,
    560             "topOrigin": "https://example.org"
    561         })
    562         .to_string();
    563         assert_eq!(
    564             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    565                 .unwrap_err()
    566                 .to_string()
    567                 .into_bytes()
    568                 .get(..err.len()),
    569             Some(err.as_slice())
    570         );
    571         // Missing `challenge`.
    572         err = Error::missing_field("challenge").to_string().into_bytes();
    573         input = serde_json::json!({
    574             "type": "webauthn.create",
    575             "origin": "https://example.com",
    576             "crossOrigin": true,
    577             "topOrigin": "https://example.org"
    578         })
    579         .to_string();
    580         assert_eq!(
    581             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    582                 .unwrap_err()
    583                 .to_string()
    584                 .into_bytes()
    585                 .get(..err.len()),
    586             Some(err.as_slice())
    587         );
    588         // `null` `type`.
    589         err = Error::invalid_type(
    590             Unexpected::Other("null"),
    591             &"'webauthn.create' or 'webauthn.get'",
    592         )
    593         .to_string()
    594         .into_bytes();
    595         input = serde_json::json!({
    596             "challenge": "ABABABABABABABABABABAA",
    597             "type": null,
    598             "origin": "https://example.com",
    599             "crossOrigin": true,
    600             "topOrigin": "https://example.org"
    601         })
    602         .to_string();
    603         assert_eq!(
    604             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    605                 .unwrap_err()
    606                 .to_string()
    607                 .into_bytes()
    608                 .get(..err.len()),
    609             Some(err.as_slice())
    610         );
    611         // Missing `type`.
    612         err = Error::missing_field("type").to_string().into_bytes();
    613         input = serde_json::json!({
    614             "challenge": "ABABABABABABABABABABAA",
    615             "origin": "https://example.com",
    616             "crossOrigin": true,
    617             "topOrigin": "https://example.org"
    618         })
    619         .to_string();
    620         assert_eq!(
    621             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    622                 .unwrap_err()
    623                 .to_string()
    624                 .into_bytes()
    625                 .get(..err.len()),
    626             Some(err.as_slice())
    627         );
    628         // `null` `origin`.
    629         err = Error::invalid_type(Unexpected::Other("null"), &"OriginWrapper")
    630             .to_string()
    631             .into_bytes();
    632         input = serde_json::json!({
    633             "challenge": "ABABABABABABABABABABAA",
    634             "type": "webauthn.get",
    635             "origin": null,
    636             "crossOrigin": true,
    637             "topOrigin": "https://example.org"
    638         })
    639         .to_string();
    640         assert_eq!(
    641             RelaxedClientDataJsonParser::<false>::parse(input.as_bytes())
    642                 .unwrap_err()
    643                 .to_string()
    644                 .into_bytes()
    645                 .get(..err.len()),
    646             Some(err.as_slice())
    647         );
    648         // Missing `origin`.
    649         err = Error::missing_field("origin").to_string().into_bytes();
    650         input = serde_json::json!({
    651             "challenge": "ABABABABABABABABABABAA",
    652             "type": "webauthn.get",
    653             "crossOrigin": true,
    654             "topOrigin": "https://example.org"
    655         })
    656         .to_string();
    657         assert_eq!(
    658             RelaxedClientDataJsonParser::<false>::parse(input.as_bytes())
    659                 .unwrap_err()
    660                 .to_string()
    661                 .into_bytes()
    662                 .get(..err.len()),
    663             Some(err.as_slice())
    664         );
    665         // Mismatched `type`.
    666         err = Error::invalid_value(Unexpected::Str("webauthn.create"), &"webauthn.get")
    667             .to_string()
    668             .into_bytes();
    669         input = serde_json::json!({
    670             "challenge": "ABABABABABABABABABABAA",
    671             "type": "webauthn.create",
    672             "origin": "https://example.com",
    673             "crossOrigin": true,
    674             "topOrigin": "https://example.org"
    675         })
    676         .to_string();
    677         assert_eq!(
    678             RelaxedClientDataJsonParser::<false>::parse(input.as_bytes())
    679                 .unwrap_err()
    680                 .to_string()
    681                 .into_bytes()
    682                 .get(..err.len()),
    683             Some(err.as_slice())
    684         );
    685         // Mismatched `type`.
    686         err = Error::invalid_value(Unexpected::Str("webauthn.get"), &"webauthn.create")
    687             .to_string()
    688             .into_bytes();
    689         input = serde_json::json!({
    690             "challenge": "ABABABABABABABABABABAA",
    691             "type": "webauthn.get",
    692             "origin": "https://example.com",
    693             "crossOrigin": true,
    694             "topOrigin": "https://example.org"
    695         })
    696         .to_string();
    697         assert_eq!(
    698             RelaxedClientDataJsonParser::<true>::parse(input.as_bytes())
    699                 .unwrap_err()
    700                 .to_string()
    701                 .into_bytes()
    702                 .get(..err.len()),
    703             Some(err.as_slice())
    704         );
    705         // `crossOrigin` can be `false` even when `topOrigin` exists.
    706         input = serde_json::json!({
    707             "challenge": "ABABABABABABABABABABAA",
    708             "type": "webauthn.get",
    709             "origin": "https://example.com",
    710             "crossOrigin": false,
    711             "topOrigin": "https://example.org"
    712         })
    713         .to_string();
    714         drop(RelaxedClientDataJsonParser::<false>::parse(input.as_bytes()).unwrap());
    715         // `crossOrigin` can be `true` even when `topOrigin` does not exist.
    716         input = serde_json::json!({
    717             "challenge": "ABABABABABABABABABABAA",
    718             "type": "webauthn.get",
    719             "origin": "https://example.com",
    720             "crossOrigin": true,
    721         })
    722         .to_string();
    723         drop(RelaxedClientDataJsonParser::<false>::parse(input.as_bytes()).unwrap());
    724         // BOM is removed.
    725         input_str = "\u{feff}{
    726             \"challenge\": \"ABABABABABABABABABABAA\",
    727             \"type\": \"webauthn.create\",
    728             \"origin\": \"https://example.com\",
    729             \"crossOrigin\": true,
    730             \"topOrigin\": \"https://example.org\"
    731         }";
    732         drop(RelaxedClientDataJsonParser::<true>::parse(input_str.as_bytes()).unwrap());
    733         // Invalid Unicode is replaced.
    734         let mut input_bytes = b"{
    735             \"challenge\": \"ABABABABABABABABABABAA\",
    736             \"type\": \"webauthn.create\",
    737             \"origin\": \"https://\xffexample.com\",
    738             \"crossOrigin\": true,
    739             \"topOrigin\": \"https://example.org\"
    740         }"
    741         .as_slice();
    742         assert!(
    743             RelaxedClientDataJsonParser::<true>::parse(input_bytes).is_ok_and(|c| {
    744                 matches!(c.origin.0, Cow::Owned(o) if o == "https://\u{fffd}example.com")
    745             })
    746         );
    747         // Escape characters are de-escaped.
    748         input_bytes = b"{
    749             \"challenge\": \"ABABABABABABABABABABAA\",
    750             \"type\": \"webauthn\\u002ecreate\",
    751             \"origin\": \"https://examp\\\\le.com\",
    752             \"crossOrigin\": true,
    753             \"topOrigin\": \"https://example.org\"
    754         }";
    755         assert!(
    756             RelaxedClientDataJsonParser::<true>::parse(input_bytes).is_ok_and(|c| {
    757                 matches!(c.origin.0, Cow::Owned(o) if o == "https://examp\\le.com")
    758             })
    759         );
    760     }
    761     #[expect(clippy::unwrap_used, reason = "OK in tests")]
    762     #[expect(clippy::little_endian_bytes, reason = "comments justify correctness")]
    763     #[expect(clippy::too_many_lines, reason = "a lot to test")]
    764     #[test]
    765     fn relaxed_challenge() {
    766         // Base case is correct.
    767         let mut input = serde_json::json!({
    768             "challenge": "ABABABABABABABABABABAA",
    769             "type": "webauthn.create",
    770             "origin": "https://example.com",
    771             "crossOrigin": true,
    772             "topOrigin": "https://example.org"
    773         })
    774         .to_string();
    775         assert!(
    776             RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok_and(
    777                 |c| {
    778                     // `Challenges` are sent in little-endian.
    779                     c.0 == u128::from_le_bytes([
    780                         0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0,
    781                     ])
    782                 }
    783             )
    784         );
    785         // Base case is correct.
    786         input = serde_json::json!({
    787             "challenge": "ABABABABABABABABABABAA",
    788             "type": "webauthn.get",
    789             "origin": "https://example.com",
    790             "crossOrigin": true,
    791             "topOrigin": "https://example.org"
    792         })
    793         .to_string();
    794         assert!(
    795             RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok_and(
    796                 |c| {
    797                     // `Challenges` are sent in little-endian.
    798                     c.0 == u128::from_le_bytes([
    799                         0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0,
    800                     ])
    801                 }
    802             )
    803         );
    804         // Unknown keys are allowed.
    805         input = serde_json::json!({
    806             "challenge": "ABABABABABABABABABABAA",
    807             "type": "webauthn.create",
    808             "origin": "https://example.com",
    809             "crossOrigin": true,
    810             "topOrigin": "https://example.org",
    811             "foo": true
    812         })
    813         .to_string();
    814         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    815         // Duplicate keys are ignored.
    816         let mut input_str = "{
    817             \"challenge\": \"ABABABABABABABABABABAA\",
    818             \"type\": \"webauthn.create\",
    819             \"origin\": \"https://example.com\",
    820             \"crossOrigin\": true,
    821             \"topOrigin\": \"https://example.org\",
    822             \"crossOrigin\": true
    823         }";
    824         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_str.as_bytes()).unwrap();
    825         // `null` `crossOrigin`.
    826         input = serde_json::json!({
    827             "challenge": "ABABABABABABABABABABAA",
    828             "type": "webauthn.create",
    829             "origin": "https://example.com",
    830             "crossOrigin": null,
    831             "topOrigin": "https://example.org"
    832         })
    833         .to_string();
    834         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    835         // Missing `crossOrigin`.
    836         input = serde_json::json!({
    837             "challenge": "ABABABABABABABABABABAA",
    838             "type": "webauthn.create",
    839             "origin": "https://example.com",
    840             "topOrigin": "https://example.org"
    841         })
    842         .to_string();
    843         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    844         // `null` `topOrigin`.
    845         input = serde_json::json!({
    846             "challenge": "ABABABABABABABABABABAA",
    847             "type": "webauthn.create",
    848             "origin": "https://example.com",
    849             "crossOrigin": true,
    850             "topOrigin": null
    851         })
    852         .to_string();
    853         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    854         // Missing `topOrigin`.
    855         input = serde_json::json!({
    856             "challenge": "ABABABABABABABABABABAA",
    857             "type": "webauthn.create",
    858             "origin": "https://example.com",
    859             "crossOrigin": true,
    860         })
    861         .to_string();
    862         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    863         // `null` `challenge`.
    864         let mut err = Error::invalid_type(
    865             Unexpected::Other("null"),
    866             &"base64 encoding of the 16-byte challenge in a URL safe way without padding",
    867         )
    868         .to_string()
    869         .into_bytes();
    870         input = serde_json::json!({
    871             "challenge": null,
    872             "type": "webauthn.create",
    873             "origin": "https://example.com",
    874             "crossOrigin": true,
    875             "topOrigin": "https://example.org"
    876         })
    877         .to_string();
    878         assert_eq!(
    879             RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes())
    880                 .unwrap_err()
    881                 .to_string()
    882                 .into_bytes()
    883                 .get(..err.len()),
    884             Some(err.as_slice())
    885         );
    886         // Missing `challenge`.
    887         err = Error::missing_field("challenge").to_string().into_bytes();
    888         input = serde_json::json!({
    889             "type": "webauthn.create",
    890             "origin": "https://example.com",
    891             "crossOrigin": true,
    892             "topOrigin": "https://example.org"
    893         })
    894         .to_string();
    895         assert_eq!(
    896             RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes())
    897                 .unwrap_err()
    898                 .to_string()
    899                 .into_bytes()
    900                 .get(..err.len()),
    901             Some(err.as_slice())
    902         );
    903         // `null` `type`.
    904         input = serde_json::json!({
    905             "challenge": "ABABABABABABABABABABAA",
    906             "type": null,
    907             "origin": "https://example.com",
    908             "crossOrigin": true,
    909             "topOrigin": "https://example.org"
    910         })
    911         .to_string();
    912         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    913         // Missing `type`.
    914         input = serde_json::json!({
    915             "challenge": "ABABABABABABABABABABAA",
    916             "origin": "https://example.com",
    917             "crossOrigin": true,
    918             "topOrigin": "https://example.org"
    919         })
    920         .to_string();
    921         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    922         // `null` `origin`.
    923         input = serde_json::json!({
    924             "challenge": "ABABABABABABABABABABAA",
    925             "type": "webauthn.get",
    926             "origin": null,
    927             "crossOrigin": true,
    928             "topOrigin": "https://example.org"
    929         })
    930         .to_string();
    931         _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    932         // Missing `origin`.
    933         input = serde_json::json!({
    934             "challenge": "ABABABABABABABABABABAA",
    935             "type": "webauthn.get",
    936             "crossOrigin": true,
    937             "topOrigin": "https://example.org"
    938         })
    939         .to_string();
    940         _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    941         // Mismatched `type`.
    942         input = serde_json::json!({
    943             "challenge": "ABABABABABABABABABABAA",
    944             "type": "webauthn.create",
    945             "origin": "https://example.com",
    946             "crossOrigin": true,
    947             "topOrigin": "https://example.org"
    948         })
    949         .to_string();
    950         _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    951         // Mismatched `type`.
    952         input = serde_json::json!({
    953             "challenge": "ABABABABABABABABABABAA",
    954             "type": "webauthn.get",
    955             "origin": "https://example.com",
    956             "crossOrigin": true,
    957             "topOrigin": "https://example.org"
    958         })
    959         .to_string();
    960         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).unwrap();
    961         // `crossOrigin` can be `false` even when `topOrigin` exists.
    962         input = serde_json::json!({
    963             "challenge": "ABABABABABABABABABABAA",
    964             "type": "webauthn.get",
    965             "origin": "https://example.com",
    966             "crossOrigin": false,
    967             "topOrigin": "https://example.org"
    968         })
    969         .to_string();
    970         _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    971         // `crossOrigin` can be `true` even when `topOrigin` does not exist.
    972         input = serde_json::json!({
    973             "challenge": "ABABABABABABABABABABAA",
    974             "type": "webauthn.get",
    975             "origin": "https://example.com",
    976             "crossOrigin": true,
    977         })
    978         .to_string();
    979         _ = RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).unwrap();
    980         // BOM is removed.
    981         input_str = "\u{feff}{
    982             \"challenge\": \"ABABABABABABABABABABAA\",
    983             \"type\": \"webauthn.create\",
    984             \"origin\": \"https://example.com\",
    985             \"crossOrigin\": true,
    986             \"topOrigin\": \"https://example.org\"
    987         }";
    988         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_str.as_bytes()).unwrap();
    989         // Invalid Unicode is replaced.
    990         let mut input_bytes = b"{
    991             \"challenge\": \"ABABABABABABABABABABAA\",
    992             \"type\": \"webauthn.create\",
    993             \"origin\": \"https://\xffexample.com\",
    994             \"crossOrigin\": true,
    995             \"topOrigin\": \"https://example.org\"
    996         }"
    997         .as_slice();
    998         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_bytes).unwrap();
    999         // Escape characters are de-escaped.
   1000         input_bytes = b"{
   1001             \"challenge\": \"ABABABABABABABABABABAA\",
   1002             \"type\": \"webauthn\\u002ecreate\",
   1003             \"origin\": \"https://examp\\\\le.com\",
   1004             \"crossOrigin\": true,
   1005             \"topOrigin\": \"https://example.org\"
   1006         }";
   1007         _ = RelaxedClientDataJsonParser::<true>::get_sent_challenge(input_bytes).unwrap();
   1008     }
   1009 }