webauthn_rp

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

commit a6985ca019cc57c8c4734cc9e5d37650812ff3d0
parent 1257be06872b64e884cc8b3fbfd224357922b746
Author: Zack Newman <zack@philomathiclife.com>
Date:   Mon, 16 Dec 2024 17:22:30 -0700

optimized challenge methods for ServerState types

Diffstat:
MCargo.toml | 2+-
Msrc/response.rs | 33+++++++++++++++++++++++++++++++++
Msrc/response/auth.rs | 39+++++++++++++++++++++++++--------------
Msrc/response/register.rs | 37+++++++++++++++++++++++--------------
Msrc/response/ser_relaxed.rs | 336++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 416 insertions(+), 31 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -24,7 +24,7 @@ p384 = { version = "0.13.0", default-features = false, features = ["ecdsa"] } precis-profiles = { version = "0.1.11", default-features = false } rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } rsa = { version = "0.9.7", default-features = false, features = ["sha2"] } -serde = { version = "1.0.215", default-features = false, features = ["alloc"], optional = true } +serde = { version = "1.0.216", default-features = false, features = ["alloc"], optional = true } serde_json = { version = "1.0.133", default-features = false, features = ["alloc"], optional = true } url = { version = "2.5.4", default-features = false } diff --git a/src/response.rs b/src/response.rs @@ -844,6 +844,12 @@ trait ClientDataJsonParser { /// /// Errors iff `json` cannot be parsed into a `CollectedClientData`. fn parse(json: &[u8]) -> Result<CollectedClientData<'_>, Self::Err>; + /// Extracts [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge) + /// from `json`. + /// + /// Note `json` should be minimally parsed such that only `challenge` is extracted; thus + /// `Ok` being returned does _not_ mean `json` is in fact valid. + fn get_sent_challenge(json: &[u8]) -> Result<SentChallenge, Self::Err>; } /// [`ClientDataJsonParser`] based on the /// [limited verification algorithm](https://www.w3.org/TR/webauthn-3/#clientdatajson-verification) @@ -1103,6 +1109,24 @@ impl<const R: bool> ClientDataJsonParser for LimitedVerificationParser<R> { } }) } + #[expect(clippy::panic_in_result_fn, reason = "want to crash when there is a bug")] + #[expect(clippy::arithmetic_side_effects, reason = "comment justifies correctness")] + #[expect(clippy::little_endian_bytes, reason = "Challenge::serialize and this need to be consistent across architectures")] + fn get_sent_challenge(json: &[u8]) -> Result<SentChallenge, Self::Err> { + // Index 39. + // `{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA"...`. + // Index 36. + // `{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA"...`. + let idx = if R { 39 } else { 36 }; + // This maxes at 39 + 22 = 61; thus overflow is not an issue. + json.get(idx..idx + Challenge::BASE64_LEN).ok_or(CollectedClientDataErr::Len).and_then(|chall_slice| { + let mut chall = [0; 16]; + BASE64URL_NOPAD_ENC.decode_mut(chall_slice, chall.as_mut_slice()).map_err(|_e| CollectedClientDataErr::Challenge).map(|len| { + assert_eq!(len, 16, "there is a bug in BASE64URL_NOPAD::decode_mut"); + SentChallenge(u128::from_le_bytes(chall)) + }) + }) + } } /// Authenticator extension outputs; pub(super) trait AuthExtOutput { @@ -1681,4 +1705,13 @@ mod tests { assert!(LimitedVerificationParser::<false>::parse(br#"{"type":"webauthn.get","challengE":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false,"foo":true}"#.as_slice()).map_or_else(|e| matches!(e, CollectedClientDataErr::ChallengeKey), |_| false)); assert!(LimitedVerificationParser::<true>::parse(br#"{"type":"webauthn.create"challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossorigin":false,"foo":true}"#.as_slice()).map_or_else(|e| matches!(e, CollectedClientDataErr::ChallengeKey), |_| false)); } + #[test] + fn c_data_challenge() { + assert!(LimitedVerificationParser::<false>::get_sent_challenge([].as_slice()).map_or_else(|e| matches!(e, CollectedClientDataErr::Len), |_| false)); + assert!(LimitedVerificationParser::<true>::get_sent_challenge([].as_slice()).map_or_else(|e| matches!(e, CollectedClientDataErr::Len), |_| false)); + assert!(LimitedVerificationParser::<true>::get_sent_challenge(b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBB").map_or_else(|e| matches!(e, CollectedClientDataErr::Challenge), |_| false)); + assert!(LimitedVerificationParser::<false>::get_sent_challenge(b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBBBB").map_or_else(|e| matches!(e, CollectedClientDataErr::Challenge), |_| false)); + assert!(LimitedVerificationParser::<true>::get_sent_challenge(b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".as_slice()).map_or(false, |c| c.0 == 0)); + assert!(LimitedVerificationParser::<false>::get_sent_challenge(b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".as_slice()).map_or(false, |c| c.0 == 0)); + } } diff --git a/src/response/auth.rs b/src/response/auth.rs @@ -2,11 +2,14 @@ use super::super::request::FixedCapHashSet; #[cfg(doc)] use super::super::{ - request::auth::{AuthenticationServerState, PublicKeyCredentialRequestOptions}, + request::{ + auth::{AuthenticationServerState, PublicKeyCredentialRequestOptions}, + Challenge, + }, AuthenticatedCredential, RegisteredCredential, StaticState, }; #[cfg(feature = "serde_relaxed")] -use super::ser_relaxed::SerdeJsonErr; +use super::ser_relaxed::{RelaxedClientDataJsonParser, SerdeJsonErr}; use super::{ super::UserHandle, auth::error::{AuthenticatorDataErr, AuthenticatorExtensionOutputErr}, @@ -14,8 +17,8 @@ use super::{ error::CollectedClientDataErr, register::CompressedPubKey, AuthData, AuthDataContainer, AuthExtOutput, AuthRespErr, AuthResponse, AuthenticatorAttachment, - CborSuccess, CollectedClientData, CredentialId, Flag, FromCbor, ParsedAuthData, Response, - SentChallenge, + CborSuccess, ClientDataJsonParser as _, CollectedClientData, CredentialId, Flag, FromCbor, + LimitedVerificationParser, ParsedAuthData, Response, SentChallenge, }; use core::convert::Infallible; use ed25519_dalek::{Signature, Verifier as _}; @@ -488,39 +491,47 @@ impl Authentication { authenticator_attachment, } } - /// Convenience function for - /// `CollectedClientData::from_client_data_json::<false>(self.response().client_data_json()).map(|c| c.challenge)`. + /// Returns the associated `SentChallenge`. /// /// This is useful when wanting to extract the corresponding [`AuthenticationServerState`] from /// an in-memory collection (e.g., [`FixedCapHashSet`]) or storage. /// + /// Note if [`CollectedClientData::from_client_data_json`] returns `Ok`, then this will return `Ok` + /// containing the same value as [`CollectedClientData::challenge`]; however the converse is _not_ true. + /// This is because this function parses the minimal amount of data possible. + /// /// # Errors /// - /// Errors iff [`CollectedClientData::from_client_data_json`] does. + /// Errors iff [`AuthenticatorAssertion::client_data_json`] does not contain a base64url-encoded + /// [`Challenge`] in the required position. #[inline] pub fn challenge(&self) -> Result<SentChallenge, CollectedClientDataErr> { - CollectedClientData::from_client_data_json::<false>( + LimitedVerificationParser::<false>::get_sent_challenge( self.response.client_data_json.as_slice(), ) - .map(|c| c.challenge) } - /// Convenience function for - /// `CollectedClientData::from_client_data_json_relaxed::<false>(self.response().client_data_json()).map(|c| c.challenge)`. + /// Returns the associated `SentChallenge`. /// /// This is useful when wanting to extract the corresponding [`AuthenticationServerState`] from /// an in-memory collection (e.g., [`FixedCapHashSet`]) or storage. /// + /// Note if [`CollectedClientData::from_client_data_json_relaxed`] returns `Ok`, then this will return + /// `Ok` containing the same value as [`CollectedClientData::challenge`]; however the converse + /// is _not_ true. This is because this function attempts to reduce the amount of data parsed. + /// /// # Errors /// - /// Errors iff [`CollectedClientData::from_client_data_json_relaxed`] does. + /// Errors iff [`AuthenticatorAssertion::client_data_json`] is invalid JSON _after_ ignoring + /// a leading U+FEFF and replacing any sequences of invalid UTF-8 code units with U+FFFD or + /// [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge) does not exist + /// or is not a base64url-encoded [`Challenge`]. #[cfg_attr(docsrs, doc(cfg(feature = "serde_relaxed")))] #[cfg(feature = "serde_relaxed")] #[inline] pub fn challenge_relaxed(&self) -> Result<SentChallenge, SerdeJsonErr> { - CollectedClientData::from_client_data_json_relaxed::<false>( + RelaxedClientDataJsonParser::<false>::get_sent_challenge( self.response.client_data_json.as_slice(), ) - .map(|c| c.challenge) } } impl Response for Authentication { diff --git a/src/response/register.rs b/src/response/register.rs @@ -1,7 +1,7 @@ #[cfg(all(doc, feature = "serde_relaxed"))] use super::super::request::FixedCapHashSet; #[cfg(feature = "serde_relaxed")] -use super::ser_relaxed::SerdeJsonErr; +use super::ser_relaxed::{RelaxedClientDataJsonParser, SerdeJsonErr}; #[cfg(all(doc, feature = "bin"))] use super::{ super::bin::{Decode, Encode}, @@ -18,8 +18,9 @@ use super::{ RsaPubKeyErr, UncompressedP256PubKeyErr, UncompressedP384PubKeyErr, }, AuthData, AuthDataContainer, AuthExtOutput, AuthRespErr, AuthResponse, AuthTransports, - AuthenticatorAttachment, Backup, CborSuccess, CollectedClientData, CredentialId, Flag, - FromCbor, ParsedAuthData, Response, SentChallenge, + AuthenticatorAttachment, Backup, CborSuccess, ClientDataJsonParser as _, CollectedClientData, + CredentialId, Flag, FromCbor, LimitedVerificationParser, ParsedAuthData, Response, + SentChallenge, }; #[cfg(doc)] use super::{ @@ -27,7 +28,7 @@ use super::{ request::{ auth::{AuthenticationVerificationOptions, PublicKeyCredentialRequestOptions}, register::{Extension, RegistrationServerState}, - BackupReq, + BackupReq, Challenge, }, AuthenticatedCredential, RegisteredCredential, }, @@ -2743,39 +2744,47 @@ impl Registration { client_extension_results, } } - /// Convenience function for - /// `CollectedClientData::from_client_data_json::<true>(self.response().client_data_json()).map(|c| c.challenge)`. + /// Returns the associated `SentChallenge`. /// /// This is useful when wanting to extract the corresponding [`RegistrationServerState`] from /// an in-memory collection (e.g., [`FixedCapHashSet`]) or storage. /// + /// Note if [`CollectedClientData::from_client_data_json`] returns `Ok`, then this will return + /// `Ok` containing the same value as [`CollectedClientData::challenge`]; however the converse + /// is _not_ true. This is because this function parses the minimal amount of data possible. + /// /// # Errors /// - /// Errors iff [`CollectedClientData::from_client_data_json`] does. + /// Errors iff [`AuthenticatorAttestation::client_data_json`] does not contain a base64url-encoded + /// [`Challenge`] in the required position. #[inline] pub fn challenge(&self) -> Result<SentChallenge, CollectedClientDataErr> { - CollectedClientData::from_client_data_json::<true>( + LimitedVerificationParser::<true>::get_sent_challenge( self.response.client_data_json.as_slice(), ) - .map(|c| c.challenge) } - /// Convenience function for - /// `CollectedClientData::from_client_data_json_relaxed::<true>(self.response().client_data_json()).map(|c| c.challenge)`. + /// Returns the associated `SentChallenge`. /// /// This is useful when wanting to extract the corresponding [`RegistrationServerState`] from /// an in-memory collection (e.g., [`FixedCapHashSet`]) or storage. /// + /// Note if [`CollectedClientData::from_client_data_json_relaxed`] returns `Ok`, then this will return + /// `Ok` containing the same value as [`CollectedClientData::challenge`]; however the converse + /// is _not_ true. This is because this function attempts to reduce the amount of data parsed. + /// /// # Errors /// - /// Errors iff [`CollectedClientData::from_client_data_json_relaxed`] does. + /// Errors iff [`AuthenticatorAttestation::client_data_json`] is invalid JSON _after_ ignoring + /// a leading U+FEFF and replacing any sequences of invalid UTF-8 code units with U+FFFD or + /// [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge) does not exist + /// or is not a base64url-encoded [`Challenge`]. #[cfg_attr(docsrs, doc(cfg(feature = "serde_relaxed")))] #[cfg(feature = "serde_relaxed")] #[inline] pub fn challenge_relaxed(&self) -> Result<SentChallenge, SerdeJsonErr> { - CollectedClientData::from_client_data_json_relaxed::<true>( + RelaxedClientDataJsonParser::<true>::get_sent_challenge( self.response.client_data_json.as_slice(), ) - .map(|c| c.challenge) } } impl Response for Registration { diff --git a/src/response/ser_relaxed.rs b/src/response/ser_relaxed.rs @@ -9,7 +9,7 @@ use super::{ AuthenticationExtensionsPrfValues, AuthenticationExtensionsPrfValuesVisitor, PRF_VALUES_FIELDS, }, - ClientDataJsonParser, CollectedClientData, Origin, + ClientDataJsonParser, CollectedClientData, Origin, SentChallenge, }; #[cfg(doc)] use super::{Challenge, LimitedVerificationParser}; @@ -79,6 +79,96 @@ impl<const R: bool> ClientDataJsonParser for RelaxedClientDataJsonParser<R> { )) .map(|val| val.0) } + fn get_sent_challenge(json: &[u8]) -> Result<SentChallenge, Self::Err> { + /// U+FEFF encoded in UTF-8. + const BOM: [u8; 3] = [0xef, 0xbb, 0xbf]; + // We avoid first calling `String::from_utf8_lossy` since `Chall` relies on + // [`Deserializer::deserialize_bytes`] instead of [`Deserializer::deserialize_identifier`]. + serde_json::from_slice::<Chall>(json.split_at_checked(BOM.len()).map_or( + json, + |(bom, rem)| { + if bom == BOM { + rem + } else { + json + } + }, + )) + .map(|c| c.0) + } +} +/// Used by [`RelaxedClientDataJsonParser::get_sent_challenge`] to minimally deserialize the JSON. +struct Chall(SentChallenge); +impl<'de> Deserialize<'de> for Chall { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `Chall`. + struct ChallVisitor; + impl<'d> Visitor<'d> for ChallVisitor { + type Value = Chall; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("Chall") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Fields in `clientDataJSON`. + enum Field { + /// `"challenge"`. + Challenge, + /// All other fields. + Other, + } + impl<'e> Deserialize<'e> for Field { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + /// `Visitor` for `Field`. + struct FieldVisitor; + impl Visitor<'_> for FieldVisitor { + type Value = Field; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "'{CHALLENGE}'") + } + fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E> + where + E: Error, + { + if v == b"challenge" { + Ok(Field::Challenge) + } else { + Ok(Field::Other) + } + } + } + deserializer.deserialize_bytes(FieldVisitor) + } + } + let mut chall = None; + while let Some(key) = map.next_key()? { + match key { + Field::Challenge => { + if chall.is_some() { + return Err(Error::duplicate_field(CHALLENGE)); + } + chall = map.next_value().map(Some)?; + } + Field::Other => map.next_value::<IgnoredAny>().map(|_| ())?, + } + } + chall + .ok_or_else(|| Error::missing_field(CHALLENGE)) + .map(Chall) + } + } + /// Fields we care about. + const FIELDS: &[&str; 1] = &[CHALLENGE]; + deserializer.deserialize_struct("Chall", FIELDS, ChallVisitor) + } } /// "type". const TYPE: &str = "type"; @@ -413,7 +503,7 @@ mod tests { \"origin\": \"https://example.com\", \"crossOrigin\": true, \"topOrigin\": \"https://example.org\", - \"crossOrigin\": true, + \"crossOrigin\": true }"; let mut err = Error::duplicate_field("crossOrigin") .to_string() @@ -671,4 +761,246 @@ mod tests { }) ); } + #[test] + fn relaxed_challenge() { + // Base case is correct. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!( + RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).map_or( + false, + |c| { + c.0 == u128::from_le_bytes([ + 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, + ]) + } + ) + ); + // Base case is correct. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.get", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!( + RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).map_or( + false, + |c| { + c.0 == u128::from_le_bytes([ + 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, 16, 1, 0, + ]) + } + ) + ); + // Unknown keys are allowed. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org", + "foo": true + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // Duplicate keys are ignored. + let input = "{ + \"challenge\": \"ABABABABABABABABABABAA\", + \"type\": \"webauthn.create\", + \"origin\": \"https://example.com\", + \"crossOrigin\": true, + \"topOrigin\": \"https://example.org\", + \"crossOrigin\": true + }"; + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // `null` `crossOrigin`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": null, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // Missing `crossOrigin`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // `null` `topOrigin`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": null + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // Missing `topOrigin`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // `null` `challenge`. + let mut err = Error::invalid_type( + Unexpected::Other("null"), + &"base64 encoding of the 16-byte challenge in a URL safe way without padding", + ) + .to_string() + .into_bytes(); + let input = serde_json::json!({ + "challenge": null, + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert_eq!( + RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()) + .unwrap_err() + .to_string() + .into_bytes()[..err.len()], + err + ); + // Missing `challenge`. + err = Error::missing_field("challenge").to_string().into_bytes(); + let input = serde_json::json!({ + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert_eq!( + RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()) + .unwrap_err() + .to_string() + .into_bytes()[..err.len()], + err + ); + // `null` `type`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": null, + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // Missing `type`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // `null` `origin`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.get", + "origin": null, + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok()); + // Missing `origin`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.get", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok()); + // Mismatched `type`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.create", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok()); + // Mismatched `type`. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.get", + "origin": "https://example.com", + "crossOrigin": true, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // `crossOrigin` can be `false` even when `topOrigin` exists. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.get", + "origin": "https://example.com", + "crossOrigin": false, + "topOrigin": "https://example.org" + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok()); + // `crossOrigin` can be `true` even when `topOrigin` does not exist. + let input = serde_json::json!({ + "challenge": "ABABABABABABABABABABAA", + "type": "webauthn.get", + "origin": "https://example.com", + "crossOrigin": true, + }) + .to_string(); + assert!(RelaxedClientDataJsonParser::<false>::get_sent_challenge(input.as_bytes()).is_ok()); + // BOM is removed. + let input = "\u{feff}{ + \"challenge\": \"ABABABABABABABABABABAA\", + \"type\": \"webauthn.create\", + \"origin\": \"https://example.com\", + \"crossOrigin\": true, + \"topOrigin\": \"https://example.org\" + }"; + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_bytes()).is_ok()); + // Invalid Unicode is replaced. + let input = b"{ + \"challenge\": \"ABABABABABABABABABABAA\", + \"type\": \"webauthn.create\", + \"origin\": \"https://\xffexample.com\", + \"crossOrigin\": true, + \"topOrigin\": \"https://example.org\" + }"; + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_slice()).is_ok()); + // Escape characters are de-escaped. + let input = b"{ + \"challenge\": \"ABABABABABABABABABABAA\", + \"type\": \"webauthn\\u002ecreate\", + \"origin\": \"https://examp\\\\le.com\", + \"crossOrigin\": true, + \"topOrigin\": \"https://example.org\" + }"; + assert!(RelaxedClientDataJsonParser::<true>::get_sent_challenge(input.as_slice()).is_ok()); + } }