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:
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());
+ }
}