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 }