webauthn_rp

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

commit d35fd32bad2cec478105a246c0ce4dc443d3523e
parent 554b521f867d6cc1b00456b90dd59b7d92607fed
Author: Zack Newman <zack@philomathiclife.com>
Date:   Mon, 11 Aug 2025 09:15:03 -0600

more trait impls. use base64url_nopad crate

Diffstat:
MCargo.toml | 12+++++++-----
Msrc/lib.rs | 78+++---------------------------------------------------------------------------
Msrc/request.rs | 166+++++++++++++++++++++++++++++++------------------------------------------------
Msrc/request/auth.rs | 35+++++++++++++++++++++++++++++++----
Msrc/request/auth/error.rs | 4++--
Msrc/request/auth/ser.rs | 766+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/request/auth/ser_server_state.rs | 4++--
Msrc/request/error.rs | 20+++++++++++++++-----
Msrc/request/register.rs | 381++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/request/register/bin.rs | 4++--
Msrc/request/register/error.rs | 6+++---
Msrc/request/register/ser.rs | 2717+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/request/register/ser_server_state.rs | 2+-
Msrc/request/ser.rs | 598++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/response.rs | 39++++++++++-----------------------------
Msrc/response/auth.rs | 2+-
Msrc/response/auth/error.rs | 12++++++------
Msrc/response/auth/ser.rs | 42++++++++++++++----------------------------
Msrc/response/auth/ser_relaxed.rs | 22+++++++++-------------
Msrc/response/bin.rs | 2+-
Msrc/response/error.rs | 6+++---
Msrc/response/register.rs | 124++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Msrc/response/register/bin.rs | 12++++++------
Msrc/response/register/error.rs | 32++++++++++++++++----------------
Msrc/response/register/ser.rs | 80+++++++++++++++++++++++++++++++++----------------------------------------------
Msrc/response/register/ser_relaxed.rs | 58++++++++++++++++++++++++++++------------------------------
Msrc/response/ser.rs | 96+++++++++----------------------------------------------------------------------
Msrc/response/ser_relaxed.rs | 2--
28 files changed, 4564 insertions(+), 758 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -9,7 +9,7 @@ license = "MIT OR Apache-2.0" name = "webauthn_rp" readme = "README.md" repository = "https://git.philomathiclife.com/repos/webauthn_rp/" -rust-version = "1.88.0" +rust-version = "1.89.0" version = "0.4.0" [lints.rust] @@ -81,9 +81,11 @@ implicit_return = "allow" min_ident_chars = "allow" missing_trait_methods = "allow" module_name_repetitions = "allow" +option_option = "allow" pub_use = "allow" pub_with_shorthand = "allow" question_mark_used = "allow" +redundant_pub_crate = "allow" ref_patterns = "allow" return_and_then = "allow" self_named_module_files = "allow" @@ -96,20 +98,20 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [dependencies] -data-encoding = { version = "2.9.0", default-features = false } +base64url_nopad = { version = "0.1.0", default-features = false } ed25519-dalek = { version = "2.2.0", default-features = false } hashbrown = { version = "0.15.4", default-features = false } p256 = { version = "0.13.2", default-features = false, features = ["ecdsa"] } p384 = { version = "0.13.1", default-features = false, features = ["ecdsa"] } precis-profiles = { version = "0.1.12", default-features = false } -rand = { version = "0.9.1", default-features = false, features = ["thread_rng"] } +rand = { version = "0.9.2", default-features = false, features = ["thread_rng"] } rsa = { version = "0.9.8", default-features = false, features = ["sha2"] } serde = { version = "1.0.219", default-features = false, features = ["alloc"], optional = true } serde_json = { version = "1.0.141", default-features = false, features = ["alloc"], optional = true } url = { version = "2.5.4", default-features = false } [dev-dependencies] -data-encoding = { version = "2.9.0", default-features = false, features = ["alloc"] } +base64url_nopad = { version = "0.1.0", default-features = false, features = ["alloc"] } ed25519-dalek = { version = "2.2.0", default-features = false, features = ["alloc", "pkcs8"] } p256 = { version = "0.13.2", default-features = false, features = ["pem"] } p384 = { version = "0.13.1", default-features = false, features = ["pkcs8"] } @@ -130,7 +132,7 @@ custom = [] # Provide client (de)serialization based on JSON-motivated # data structures. -serde = ["data-encoding/alloc", "dep:serde"] +serde = ["base64url_nopad/alloc", "dep:serde"] # Provide "relaxed" JSON deserialization implementations. serde_relaxed = ["serde", "dep:serde_json"] diff --git a/src/lib.rs b/src/lib.rs @@ -24,7 +24,7 @@ //! Registration, RegistrationServerState64, //! hash::hash_set::FixedCapHashSet, //! request::{ -//! AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId, +//! PublicKeyCredentialDescriptor, RpId, //! auth::AuthenticationVerificationOptions, //! register::{ //! Nickname, PublicKeyCredentialUserEntity64, RegistrationVerificationOptions, @@ -42,7 +42,7 @@ //! # #[cfg(feature = "serde_relaxed")] //! use serde_json::Error as JsonErr; //! /// The RP ID our application uses. -//! const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +//! const RP_ID: &RpId = &RpId::from_static_domain("example.com").unwrap(); //! /// The registration verification options. //! const REG_OPTS: &RegistrationVerificationOptions::<'static, 'static, &'static str, &'static str> = &RegistrationVerificationOptions::new(); //! /// The authentication verification options. @@ -677,7 +677,7 @@ pub use crate::{ }, }; /// Error returned in [`RegCeremonyErr::Credential`] and [`AuthenticatedCredential::new`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CredentialErr { /// Variant when [`CredentialProtectionPolicy::UserVerificationRequired`], but /// [`DynamicState::user_verified`] is `false`. @@ -1460,75 +1460,3 @@ impl Display for AggErr { } } impl Error for AggErr {} -/// Calculates the number of bytes needed to encode an input of length `n` bytes into base64url. -/// -/// # Panics -/// -/// `panics` iff `n > isize::MAX`. -#[expect( - clippy::arithmetic_side_effects, - clippy::as_conversions, - clippy::integer_division, - clippy::integer_division_remainder_used, - reason = "proof and comment justifies their correctness" -)] -const fn base64url_nopad_len(n: usize) -> usize { - // 256^n is the number of distinct values of the input. Let the base64 encoding in a URL safe - // way without padding of the input be O. There are 64 possible values each byte in O can be; thus we must find - // the minimum nonnegative integer m such that: - // 64^m = (2^6)^m = 2^(6m) >= 256^n = (2^8)^n = 2^(8n) - // <==> - // lg(2^(6m)) = 6m >= lg(2^(8n)) = 8n lg is defined on all positive reals which 2^(6m) and 2^(8n) are - // <==> - // m >= 8n/6 = 4n/3 - // Clearly that corresponds to m = ⌈4n/3⌉. - // We claim ⌈4n/3⌉ = 4⌊n/3⌋ + ⌈4(n mod 3)/3⌉. - // Proof: - // There are three partitions for n: - // (1) 3i = n ≡ 0 (mod 3) for some integer i - // <==> - // ⌈4n/3⌉ = ⌈4(3i)/3⌉ = ⌈4i⌉ = 4i = 4⌊i⌋ = 4⌊3i/3⌋ = 4⌊n/3⌋ + 0 = 4⌊n/3⌋ + ⌈4(0)/3⌉ = 4⌊n/3⌋ + ⌈4(n mod 3)/3⌉ - // (2) 3i + 1 = n ≡ 1 (mod 3) for some integer i - // <==> - // ⌈4n/3⌉ = ⌈4(3i + 1)/3⌉ = ⌈4i + 4/3⌉ = 4i + ⌈4/3⌉ = 4i + 2 = 4⌊i + 1/3⌋ + ⌈4(1)/3⌉ - // = 4⌊(3i + 1)/3⌋ + ⌈4((3i + 1) mod 3)/3⌉ - // = 4⌊n/3⌋ + ⌈4(n mod 3)/3⌉ - // (3) 3i + 2 = n ≡ 2 (mod 3) for some integer i - // <==> - // ⌈4n/3⌉ = ⌈4(3i + 2)/3⌉ = ⌈4i + 8/3⌉ = 4i + ⌈8/3⌉ = 4i + 3 = 4⌊i + 2/3⌋ + ⌈4(2)/3⌉ - // = 4⌊(3i + 2)/3⌋ + ⌈4((3i + 2) mod 3)/3⌉ - // = 4⌊n/3⌋ + ⌈4(n mod 3)/3⌉ - // QED - // Proof of no overflow: - // usize >= u16::MAX - // usize = 2^i - 1 where i is any integer >= 16 (due to above) - // isize = 2^(i-1) - 1 - // Suppose n <= isize::MAX, then: - // ⌈4n/3⌉ <= ⌈4*isize::MAX/3⌉ = ⌈4*(2^(i-1)-1)/3⌉ - // = ⌈(2^(i+1)-4)/3⌉ - // < ⌈(2^(i+1)-4)/2⌉ - // = ⌈2^i-2⌉ = 2^i-2 < 2^i - 1 = usize::MAX - // thus ignoring intermediate calcuations, overflow is not possible. - // QED - // Naively implementing ⌈4n/3⌉ as (4 * n).div_ceil(3) can cause overflow due to `4 * n`; thus - // we implement the equivalent equation 4⌊n/3⌋ + ⌈4(n mod 3)/3⌉ instead: - // `(4 * (n / 3)) + (4 * (n % 3)).div_ceil(3)` since none of the intermediate calculations suffer - // from overflow. - - // `isize::MAX > 0 = usize::MIN`; thus this conversion is lossless. - assert!( - n <= isize::MAX as usize, - "base64url_nopad_len must be passed a `const` that is no larger than isize::MAX" - ); - // n = 3quot + rem. - let (quot, rem) = (n / 3, n % 3); - // quot << 2u8 <= m < usize::MAX; thus the left operand of + is fine. - // rem <= 2 - // <==> - // 4rem <= 8 < usize::MAX; thus rem << 2u8 is fine. - // <==> - // ⌈4rem/3⌉ <= 4rem, so the right operand of + is fine. - // The sum is fine since - // m = ⌈4n/3⌉ = 4⌊n/3⌋ + ⌈4(n mod 3)/3⌉ = (quot << 2u8) + (rem << 2u8).div_ceil(3), and m < usize::MAX. - (quot << 2u8) + (rem << 2u8).div_ceil(3) -} diff --git a/src/request.rs b/src/request.rs @@ -14,7 +14,9 @@ use super::{ }; use crate::{ request::{ - error::{AsciiDomainErr, DomainOriginParseErr, PortParseErr, SchemeParseErr, UrlErr}, + error::{ + AsciiDomainErr, DomainOriginParseErr, PortParseErr, RpIdErr, SchemeParseErr, UrlErr, + }, register::RegistrationVerificationOptions, }, response::{ @@ -46,12 +48,12 @@ use url::Url as Uri; /// # request::{ /// # auth::{AllowedCredentials, DiscoverableCredentialRequestOptions, NonDiscoverableCredentialRequestOptions}, /// # register::UserHandle64, -/// # AsciiDomainStatic, Credentials, PublicKeyCredentialDescriptor, RpId, +/// # Credentials, PublicKeyCredentialDescriptor, RpId, /// # }, /// # response::{AuthTransports, CredentialId, CRED_ID_MIN_LEN}, /// # AggErr, /// # }; -/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +/// const RP_ID: &RpId = &RpId::from_static_domain("example.com").unwrap(); /// let mut ceremonies = FixedCapHashSet::new(128); /// let (server, client) = DiscoverableCredentialRequestOptions::passkey(RP_ID).start_ceremony()?; /// assert!( @@ -110,12 +112,12 @@ pub mod error; /// # register::{ /// # CredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle, USER_HANDLE_MAX_LEN, UserHandle64, /// # }, -/// # AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId +/// # PublicKeyCredentialDescriptor, RpId /// # }, /// # response::{AuthTransports, CredentialId, CRED_ID_MIN_LEN}, /// # AggErr, /// # }; -/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +/// const RP_ID: &RpId = &RpId::from_static_domain("example.com").unwrap(); /// # #[cfg(feature = "custom")] /// let mut ceremonies = FixedCapHashSet::new(128); /// # #[cfg(feature = "custom")] @@ -201,7 +203,7 @@ pub(super) mod ser_server_state; pub struct Challenge(u128); impl Challenge { /// The number of bytes a `Challenge` takes to encode in base64url. - pub(super) const BASE64_LEN: usize = super::base64url_nopad_len(16); + pub(super) const BASE64_LEN: usize = base64url_nopad::encode_len(16); /// Generates a random `Challenge`. /// /// # Examples @@ -674,6 +676,16 @@ pub enum RpId { Url(Url), } impl RpId { + /// Returns `Some` containing an [`AsciiDomainStatic`] iff [`AsciiDomainStatic::new`] does. + #[inline] + #[must_use] + pub const fn from_static_domain(domain: &'static str) -> Option<Self> { + if let Some(dom) = AsciiDomainStatic::new(domain) { + Some(Self::StaticDomain(dom)) + } else { + None + } + } /// Validates `hash` is the same as the SHA-256 hash of `self`. fn validate_rp_id_hash<E>(&self, hash: &[u8]) -> Result<(), CeremonyErr<E>> { if hash == Sha256::digest(self.as_ref()).as_slice() { @@ -743,6 +755,22 @@ impl From<Url> for RpId { Self::Url(value) } } +impl TryFrom<String> for RpId { + type Error = RpIdErr; + /// Returns `Ok` iff `value` is a valid [`Url`] or [`AsciiDomain`]. + /// + /// Note when `value` is a valid `Url` and `AsciiDomain`, it will be treated as a `Url`. + #[inline] + fn try_from(value: String) -> Result<Self, Self::Error> { + Url::from_str(value.as_str()) + .map(Self::Url) + .or_else(|_err| { + AsciiDomain::try_from(value) + .map(Self::Domain) + .map_err(|_e| RpIdErr) + }) + } +} /// A URI scheme. This can be used to make /// [origin validation](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin) more convenient. #[derive(Clone, Copy, Debug, Default)] @@ -1117,7 +1145,7 @@ pub struct PublicKeyCredentialDescriptor<T> { pub transports: AuthTransports, } /// [`UserVerificationRequirement`](https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum UserVerificationRequirement { /// [`required`](https://www.w3.org/TR/webauthn-3/#dom-userverificationrequirement-required). Required, @@ -1130,18 +1158,8 @@ pub enum UserVerificationRequirement { /// [`preferred`](https://www.w3.org/TR/webauthn-3/#dom-userverificationrequirement-preferred). Preferred, } -#[cfg(test)] -impl PartialEq for UserVerificationRequirement { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::Required => matches!(other, Self::Required), - Self::Discouraged => matches!(other, Self::Discouraged), - Self::Preferred => matches!(other, Self::Preferred), - } - } -} /// [`PublicKeyCredentialHints`](https://www.w3.org/TR/webauthn-3/#enumdef-publickeycredentialhint). -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum Hint { /// No hints. #[default] @@ -1177,69 +1195,24 @@ pub enum Hint { /// [`Self::HybridClientDevice`] and [`Self::SecurityKey`]. HybridClientDeviceSecurityKey, } -#[cfg(test)] -impl PartialEq for Hint { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::None => matches!(other, Self::None), - Self::SecurityKey => matches!(other, Self::SecurityKey), - Self::ClientDevice => matches!(other, Self::ClientDevice), - Self::Hybrid => matches!(other, Self::Hybrid), - Self::SecurityKeyClientDevice => matches!(other, Self::SecurityKeyClientDevice), - Self::ClientDeviceSecurityKey => matches!(other, Self::ClientDeviceSecurityKey), - Self::SecurityKeyHybrid => matches!(other, Self::SecurityKeyHybrid), - Self::HybridSecurityKey => matches!(other, Self::HybridSecurityKey), - Self::ClientDeviceHybrid => matches!(other, Self::ClientDeviceHybrid), - Self::HybridClientDevice => matches!(other, Self::HybridClientDevice), - Self::SecurityKeyClientDeviceHybrid => { - matches!(other, Self::SecurityKeyClientDeviceHybrid) - } - Self::SecurityKeyHybridClientDevice => { - matches!(other, Self::SecurityKeyHybridClientDevice) - } - Self::ClientDeviceSecurityKeyHybrid => { - matches!(other, Self::ClientDeviceSecurityKeyHybrid) - } - Self::ClientDeviceHybridSecurityKey => { - matches!(other, Self::ClientDeviceHybridSecurityKey) - } - Self::HybridSecurityKeyClientDevice => { - matches!(other, Self::HybridSecurityKeyClientDevice) - } - Self::HybridClientDeviceSecurityKey => { - matches!(other, Self::HybridClientDeviceSecurityKey) - } - } - } -} /// Controls if the response to a requested extension is required to be sent back. /// /// Note when requiring an extension, the extension must not only be sent back but also -/// contain at least one expected field -/// (e.g., [`ClientExtensionsOutputs::cred_props`] must be +/// contain at least one expected field (e.g., [`ClientExtensionsOutputs::cred_props`] must be /// `Some(CredentialPropertiesOutput { rk: Some(_) })`. /// /// If one wants to additionally control the values of an extension, use [`ExtensionInfo`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExtensionReq { /// The response to a requested extension is required to be sent back. Require, /// The response to a requested extension is allowed, but not required, to be sent back. Allow, } -#[cfg(test)] -impl PartialEq for ExtensionReq { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::Require => matches!(other, Self::Require), - Self::Allow => matches!(other, Self::Allow), - } - } -} /// Dictates how an extension should be processed. /// /// If one wants to only control if the extension should be returned, use [`ExtensionReq`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExtensionInfo { /// Require the associated extension and enforce its value. RequireEnforceValue, @@ -1250,17 +1223,6 @@ pub enum ExtensionInfo { /// Allow the associated extension to exist but don't enforce its value. AllowDontEnforceValue, } -#[cfg(test)] -impl PartialEq for ExtensionInfo { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::RequireEnforceValue => matches!(other, Self::RequireEnforceValue), - Self::RequireDontEnforceValue => matches!(other, Self::RequireDontEnforceValue), - Self::AllowEnforceValue => matches!(other, Self::AllowEnforceValue), - Self::AllowDontEnforceValue => matches!(other, Self::AllowDontEnforceValue), - } - } -} impl Display for ExtensionInfo { #[inline] fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { @@ -1279,7 +1241,7 @@ impl Display for ExtensionInfo { /// [`optional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-optional) /// is just an alias for [`Self::Required`]. #[expect(clippy::doc_markdown, reason = "false positive")] -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum CredentialMediationRequirement { /// [`required`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-required). /// @@ -1289,21 +1251,12 @@ pub enum CredentialMediationRequirement { /// [`conditional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-conditional). /// /// Note that when registering a new credential with [`CredentialCreationOptions::mediation`] set to - /// `Self::Conditional`, [`UserVerificationRequirement::Discouraged`] MUST be used unless user verification + /// `Self::Conditional`, [`UserVerificationRequirement::Required`] MUST NOT be used unless user verification /// can be explicitly performed during the ceremony. Conditional, } -#[cfg(test)] -impl PartialEq for CredentialMediationRequirement { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::Required => matches!(other, Self::Required), - Self::Conditional => matches!(other, Self::Conditional), - } - } -} /// Backup requirements for the credential. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum BackupReq { /// No requirements (i.e., any [`Backup`] is allowed). #[default] @@ -1723,20 +1676,35 @@ pub trait TimedCeremony { fn expiration(&self) -> SystemTime; } /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PrfInput<'first, 'second> { /// [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first). pub first: &'first [u8], /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second). pub second: Option<&'second [u8]>, } -#[cfg(test)] -impl PartialEq for PrfInput<'_, '_> { - fn eq(&self, other: &Self) -> bool { - self.first == other.first && self.second == other.second +impl<'first, 'second> PrfInput<'first, 'second> { + /// Returns a `PrfInput` with [`Self::first`] set to `first` and [`Self::second`] set to `None`. + #[expect(single_use_lifetimes, reason = "false positive")] + #[inline] + #[must_use] + pub const fn with_first<'a: 'first>(first: &'a [u8]) -> Self { + Self { + first, + second: None, + } + } + /// Same as [`Self::with_first`] except [`Self::second`] is set to `Some` containing `second`. + #[expect(single_use_lifetimes, reason = "false positive")] + #[inline] + #[must_use] + pub const fn with_two<'a: 'first, 'b: 'second>(first: &'a [u8], second: &'b [u8]) -> Self { + Self { + first, + second: Some(second), + } } } - /// The number of milliseconds in 5 minutes. /// /// This is the recommended default timeout duration for ceremonies @@ -1844,7 +1812,7 @@ mod tests { assert!(AsciiDomainStatic::new("λ.com").is_none()); } #[cfg(feature = "custom")] - const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); + const RP_ID: &RpId = &RpId::from_static_domain("example.com").unwrap(); #[test] #[cfg(feature = "custom")] fn eddsa_reg() -> Result<(), AggErr> { @@ -1865,11 +1833,7 @@ mod tests { false, ExtensionInfo::RequireEnforceValue, ), - min_pin_length: Some(( - FourToSixtyThree::new(10) - .unwrap_or_else(|| unreachable!("bug in FourToSixyThree::new")), - ExtensionInfo::RequireEnforceValue, - )), + min_pin_length: Some((FourToSixtyThree::Ten, ExtensionInfo::RequireEnforceValue)), prf: Some(( PrfInput { first: [0].as_slice(), diff --git a/src/request/auth.rs b/src/request/auth.rs @@ -45,7 +45,7 @@ pub mod error; /// Contains functionality to serialize data to a client. #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] #[cfg(feature = "serde")] -mod ser; +pub mod ser; /// Contains functionality to (de)serialize [`DiscoverableAuthenticationServerState`] and /// [`NonDiscoverableAuthenticationServerState`] to a data store. #[cfg_attr(docsrs, doc(cfg(feature = "serializable_server_state")))] @@ -55,7 +55,7 @@ pub mod ser_server_state; /// /// Note that if the previous signature counter is positive and the new counter is not strictly greater, then the /// authenticator is likely a clone (i.e., there are at least two copies of the private key). -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum SignatureCounterEnforcement { /// Fail the authentication ceremony if the counter is less than or equal to the previous value when the /// previous value is positive. @@ -102,7 +102,7 @@ pub struct PrfInputOwned { pub ext_req: ExtensionReq, } /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug)] pub struct Extension<'prf_first, 'prf_second> { /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension). /// @@ -115,6 +115,33 @@ pub struct Extension<'prf_first, 'prf_second> { /// extension since the data is encrypted and is part of the [`AuthenticatorData`]. pub prf: Option<(PrfInput<'prf_first, 'prf_second>, ExtensionReq)>, } +impl<'prf_first, 'prf_second> Extension<'prf_first, 'prf_second> { + /// Returns an `Extension` with [`Self::prf`] set to `None`. + #[inline] + #[must_use] + pub const fn none() -> Self { + Self { prf: None } + } + /// Returns an `Extension` with [`Self::prf`] set to `None`. + #[expect(single_use_lifetimes, reason = "false positive")] + #[inline] + #[must_use] + pub const fn with_prf<'a: 'prf_first, 'b: 'prf_second>( + input: PrfInput<'a, 'b>, + req: ExtensionReq, + ) -> Self { + Self { + prf: Some((input, req)), + } + } +} +impl Default for Extension<'_, '_> { + /// Same as [`Self::none`]. + #[inline] + fn default() -> Self { + Self::none() + } +} /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client that /// are credential-specific which among other things implies a non-discoverable request. #[derive(Clone, Debug, Default)] @@ -885,7 +912,7 @@ impl PartialEq for CredInfo { /// Note when `DynamicState::authenticator_attachment` is [`AuthenticatorAttachment::None`], then it will /// be updated regardless. Similarly when [`Authentication::authenticator_attachment`] is /// `AuthenticatorAttachment::None`, it will never update `DynamicState::authenticator_attachment`. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorAttachmentEnforcement { /// Fail the authentication ceremony if [`AuthenticatorAttachment`] is not the same. /// diff --git a/src/request/auth/error.rs b/src/request/auth/error.rs @@ -14,7 +14,7 @@ use std::time::{Instant, SystemTime}; /// /// This happens when [`PublicKeyCredentialRequestOptions::timeout`] could not be added to [`Instant::now`] or /// [`SystemTime::now`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct InvalidTimeout; impl Display for InvalidTimeout { #[inline] @@ -24,7 +24,7 @@ impl Display for InvalidTimeout { } impl Error for InvalidTimeout {} /// Error returned by [`NonDiscoverableCredentialRequestOptions::start_ceremony`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NonDiscoverableCredentialRequestOptionsErr { /// Variant when [`NonDiscoverableCredentialRequestOptions::allow_credentials`] is /// empty. diff --git a/src/request/auth/ser.rs b/src/request/auth/ser.rs @@ -1,10 +1,24 @@ +#[cfg(doc)] +use super::ExtensionReq; use super::{ - AllowedCredential, AllowedCredentials, Credentials as _, DiscoverableAuthenticationClientState, - DiscoverableCredentialRequestOptions, Extension, NonDiscoverableAuthenticationClientState, + super::{ + super::response::ser::Null, + ser::{DEFAULT_RP_ID, PrfHelper}, + }, + AllowedCredential, AllowedCredentials, Challenge, CredentialMediationRequirement, + Credentials as _, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, + Extension, FIVE_MINUTES, Hint, NonDiscoverableAuthenticationClientState, NonDiscoverableCredentialRequestOptions, PrfInput, PrfInputOwned, - PublicKeyCredentialRequestOptions, + PublicKeyCredentialRequestOptions, RpId, UserVerificationRequirement, +}; +use core::{ + fmt::{self, Formatter}, + num::NonZeroU32, +}; +use serde::{ + de::{Deserialize, Deserializer, Error, MapAccess, Visitor}, + ser::{Serialize, SerializeMap as _, SerializeStruct as _, Serializer}, }; -use serde::ser::{Serialize, SerializeMap as _, SerializeStruct as _, Serializer}; impl Serialize for PrfInputOwned { /// See [`PrfInput::serialize`] #[inline] @@ -203,6 +217,20 @@ impl Serialize for ExtensionHelper<'_, '_, '_> { }) } } +/// `"challenge"` +const CHALLENGE: &str = "challenge"; +/// `"timeout"` +const TIMEOUT: &str = "timeout"; +/// `"rpId"` +const RP_ID: &str = "rpId"; +/// `"allowCredentials"` +const ALLOW_CREDENTIALS: &str = "allowCredentials"; +/// `"extensions"` +const EXTENSIONS: &str = "extensions"; +/// `"hints"` +const HINTS: &str = "hints"; +/// `"userVerification"` +const USER_VERIFICATION: &str = "userVerification"; /// Helper type that peforms the serialization for both [`DiscoverableAuthenticationClientState`] and /// [`NonDiscoverableAuthenticationClientState`] and struct AuthenticationClientState<'rp_id, 'prf_first, 'prf_second, 'opt, 'cred>( @@ -218,23 +246,23 @@ impl Serialize for AuthenticationClientState<'_, '_, '_, '_, '_> { serializer .serialize_struct("PublicKeyCredentialRequestOptions", 7) .and_then(|mut ser| { - ser.serialize_field("challenge", &self.0.challenge) + ser.serialize_field(CHALLENGE, &self.0.challenge) .and_then(|()| { - ser.serialize_field("timeout", &self.0.timeout) + ser.serialize_field(TIMEOUT, &self.0.timeout) .and_then(|()| { - ser.serialize_field("rpId", &self.0.rp_id).and_then(|()| { - ser.serialize_field("allowCredentials", &self.1).and_then( - |()| { + ser.serialize_field(RP_ID, &self.0.rp_id).and_then(|()| { + ser.serialize_field(ALLOW_CREDENTIALS, &self.1) + .and_then(|()| { ser.serialize_field( - "userVerification", + USER_VERIFICATION, &self.0.user_verification, ) .and_then( |()| { - ser.serialize_field("hints", &self.0.hints) + ser.serialize_field(HINTS, &self.0.hints) .and_then(|()| { ser.serialize_field( - "extensions", + EXTENSIONS, &ExtensionHelper { extension: &self.0.extensions, allow_credentials: self.1, @@ -244,8 +272,7 @@ impl Serialize for AuthenticationClientState<'_, '_, '_, '_, '_> { }) }, ) - }, - ) + }) }) }) }) @@ -490,3 +517,714 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_, '_> { self.0.serialize(serializer) } } +/// Similar to [`Extension`] except [`PrfInputOwned`] is used. +/// +/// This is primarily useful to assist [`ClientCredentialRequestOptions::deserialize`]. +#[derive(Debug, Default)] +pub struct ExtensionOwned { + /// See [`Extension::prf`]. + pub prf: Option<PrfInputOwned>, +} +impl<'a: 'prf_first + 'prf_second, 'prf_first, 'prf_second> From<&'a ExtensionOwned> + for Extension<'prf_first, 'prf_second> +{ + #[inline] + fn from(value: &'a ExtensionOwned) -> Self { + Self { + prf: value.prf.as_ref().map(|input| { + ( + PrfInput { + first: input.first.as_slice(), + second: input.second.as_deref(), + }, + input.ext_req, + ) + }), + } + } +} +impl<'de> Deserialize<'de> for ExtensionOwned { + /// Deserializes a `struct` according to the following pseudo-schema: + /// + /// ```json + /// { + /// "prf": null | PRFJSON + /// } + /// // PRFJSON: + /// { + /// "eval": PRFInputs + /// } + /// // PRFInputs: + /// { + /// "first": <base64url-encoded string>, + /// "second": null | <base64url-encoded string> + /// } + /// ``` + /// + /// where the only required fields are `"eval"` and `"first"`. + /// + /// All extensions are not required to have a response sent back; but _if_ a response is sent back, its value + /// will be enforced. + /// + /// Unknown or duplicate fields lead to an error. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::{ExtensionReq, auth::ser::ExtensionOwned}; + /// let ext = serde_json::from_str::<ExtensionOwned>( + /// r#"{"prf":{"eval":{"first":"","second":null}}}"#, + /// )?; + /// assert!(ext.prf.map_or(false, |prf| prf.first.is_empty() + /// && prf.second.is_none() + /// && matches!(prf.ext_req, ExtensionReq::Allow))); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `ExtensionOwned`. + struct ExtensionOwnedVisitor; + impl<'d> Visitor<'d> for ExtensionOwnedVisitor { + type Value = ExtensionOwned; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("ExtensionOwned") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `ExtensionOwned`. + struct Field; + 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, "'{PRF}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + if v == PRF { + Ok(Field) + } else { + Err(E::unknown_field(v, FIELDS)) + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + map.next_key::<Field>().and_then(|opt_key| { + opt_key + .map_or_else( + || Ok(None), + |_k| { + map.next_value::<Option<PrfHelper>>().and_then(|prf| { + map.next_key::<Field>().and_then(|opt_key2| { + opt_key2.map_or_else( + || Ok(prf.map(|val| val.0)), + |_k2| Err(Error::duplicate_field(PRF)), + ) + }) + }) + }, + ) + .map(|prf| ExtensionOwned { prf }) + }) + } + } + /// `"prf"`. + const PRF: &str = "prf"; + /// Fields for `ExtensionOwned`. + const FIELDS: &[&str; 1] = &[PRF]; + deserializer.deserialize_struct("ExtensionOwned", FIELDS, ExtensionOwnedVisitor) + } +} +/// Similar to [`PublicKeyCredentialRequestOptions`] except the fields are based on owned data. +/// +/// This is primarily useful to assist [`ClientCredentialRequestOptions::deserialize`], +#[derive(Debug)] +pub struct PublicKeyCredentialRequestOptionsOwned { + /// See [`PublicKeyCredentialRequestOptions::rp_id`]. + pub rp_id: RpId, + /// See [`PublicKeyCredentialRequestOptions::timeout`]. + pub timeout: NonZeroU32, + /// See [`PublicKeyCredentialRequestOptions::user_verification`]. + pub user_verification: UserVerificationRequirement, + /// See [`PublicKeyCredentialRequestOptions::hints`]. + pub hints: Hint, + /// See [`PublicKeyCredentialRequestOptions::extensions`]. + pub extensions: ExtensionOwned, +} +impl PublicKeyCredentialRequestOptionsOwned { + /// Creates a `PublicKeyCredentialRequestOptions` based on the contained data and randomly-generated + /// [`Challenge`]. + #[inline] + #[must_use] + pub fn into_options(&self) -> PublicKeyCredentialRequestOptions<'_, '_, '_> { + PublicKeyCredentialRequestOptions { + rp_id: &self.rp_id, + challenge: Challenge::new(), + timeout: self.timeout, + user_verification: self.user_verification, + hints: self.hints, + extensions: (&self.extensions).into(), + } + } +} +impl Default for PublicKeyCredentialRequestOptionsOwned { + #[inline] + fn default() -> Self { + Self { + rp_id: DEFAULT_RP_ID, + timeout: FIVE_MINUTES, + user_verification: UserVerificationRequirement::Preferred, + hints: Hint::default(), + extensions: ExtensionOwned::default(), + } + } +} +impl<'de> Deserialize<'de> for PublicKeyCredentialRequestOptionsOwned { + /// Deserializes a `struct` based on + /// [`PublicKeyCredentialRequestOptionsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptionsjson). + /// + /// Note that none of the fields are required, and all are allowed to be `null`. + /// + /// If [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptionsjson-challenge) + /// exists, it must be `null`. If + /// [`allowCredentials`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptionsjson-allowcredentials) + /// exists, it must be `null` or empty. + /// + /// If [`timeout`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptionsjson-timeout) exists, + /// it must be `null` or positive. + /// + /// In the event there is no RP ID defined, the value `"example.invalid"` will be used. + /// + /// For any field that does not exist or is `null`, the corresponding [`Default`] `impl` will be used. For + /// `user_verification`, [`UserVerificationRequirement::Preferred`] will be used. For `timeout`, + /// [`FIVE_MINUTES`] will be used. + /// + /// Unknown or duplicate fields lead to an error. + #[expect(clippy::too_many_lines, reason = "131 lines is fine")] + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `PublicKeyCredentialRequestOptionsOwned`. + struct PublicKeyCredentialRequestOptionsOwnedVisitor; + impl<'d> Visitor<'d> for PublicKeyCredentialRequestOptionsOwnedVisitor { + type Value = PublicKeyCredentialRequestOptionsOwned; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("PublicKeyCredentialRequestOptionsOwned") + } + #[expect(clippy::too_many_lines, reason = "104 lines is fine")] + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `PublicKeyCredentialRequestOptionsOwned`. + enum Field { + /// `rpId`. + RpId, + /// `userVerification`. + UserVerification, + /// `challenge`. + Challenge, + /// `timeout`. + Timeout, + /// `allowCredentials`. + AllowCredentials, + /// `hints`. + Hints, + /// `extensions`. + Extensions, + } + 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, + "'{RP_ID}', '{USER_VERIFICATION}', '{CHALLENGE}', '{TIMEOUT}', '{ALLOW_CREDENTIALS}', '{HINTS}', or '{EXTENSIONS}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + RP_ID => Ok(Field::RpId), + USER_VERIFICATION => Ok(Field::UserVerification), + CHALLENGE => Ok(Field::Challenge), + TIMEOUT => Ok(Field::Timeout), + ALLOW_CREDENTIALS => Ok(Field::AllowCredentials), + HINTS => Ok(Field::Hints), + EXTENSIONS => Ok(Field::Extensions), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut rp = None; + let mut user_veri = None; + let mut chall = None; + let mut time = None; + let mut allow = None; + let mut hint = None; + let mut ext = None; + while let Some(key) = map.next_key()? { + match key { + Field::RpId => { + if rp.is_some() { + return Err(Error::duplicate_field(RP_ID)); + } + rp = map.next_value::<Option<RpId>>().map(Some)?; + } + Field::UserVerification => { + if user_veri.is_some() { + return Err(Error::duplicate_field(USER_VERIFICATION)); + } + user_veri = map.next_value::<Option<_>>().map(Some)?; + } + Field::Challenge => { + if chall.is_some() { + return Err(Error::duplicate_field(CHALLENGE)); + } + chall = map.next_value::<Null>().map(Some)?; + } + Field::Timeout => { + if time.is_some() { + return Err(Error::duplicate_field(TIMEOUT)); + } + time = map.next_value::<Option<_>>().map(Some)?; + } + Field::AllowCredentials => { + if allow.is_some() { + return Err(Error::duplicate_field(ALLOW_CREDENTIALS)); + } + allow = map.next_value::<Option<[(); 0]>>().map(Some)?; + } + Field::Hints => { + if hint.is_some() { + return Err(Error::duplicate_field(HINTS)); + } + hint = map.next_value::<Option<_>>().map(Some)?; + } + Field::Extensions => { + if ext.is_some() { + return Err(Error::duplicate_field(EXTENSIONS)); + } + ext = map.next_value::<Option<_>>().map(Some)?; + } + } + } + Ok(PublicKeyCredentialRequestOptionsOwned { + rp_id: rp.flatten().unwrap_or(DEFAULT_RP_ID), + user_verification: user_veri + .flatten() + .unwrap_or(UserVerificationRequirement::Preferred), + timeout: time.flatten().unwrap_or(FIVE_MINUTES), + extensions: ext.flatten().unwrap_or_default(), + hints: hint.flatten().unwrap_or_default(), + }) + } + } + /// Fields for `PublicKeyCredentialRequestOptionsOwned`. + const FIELDS: &[&str; 7] = &[ + RP_ID, + USER_VERIFICATION, + CHALLENGE, + TIMEOUT, + ALLOW_CREDENTIALS, + HINTS, + EXTENSIONS, + ]; + deserializer.deserialize_struct( + "PublicKeyCredentialRequestOptionsOwned", + FIELDS, + PublicKeyCredentialRequestOptionsOwnedVisitor, + ) + } +} +/// Deserializes client-supplied data to assist in the creation of [`DiscoverableCredentialRequestOptions`] +/// and [`NonDiscoverableCredentialRequestOptions`]. +/// +/// It's common to tailor an authentication ceremony based on a user's environment. The options that should be +/// used are then sent to the server. To facilitate this, [`Self::deserialize`] can be used to deserialize the data +/// sent from the client. Upon successful deserialization, [`Self::into_discoverable_options`] and +/// [`Self::into_non_discoverable_options`] can then be used to construct the +/// appropriate [`DiscoverableCredentialRequestOptions`] and [`NonDiscoverableCredentialRequestOptions`] +/// respectively. +/// +/// Note one may want to change some of the [`Extension`] data since [`ExtensionReq::Allow`] is unconditionally +/// used. Read [`ExtensionOwned::deserialize`] for more information. +/// +/// Additionally, one may want to change the value of [`PublicKeyCredentialRequestOptions::rp_id`] since +/// `"example.invalid"` is used in the event the RP ID was not supplied. +#[derive(Debug)] +pub struct ClientCredentialRequestOptions { + /// See [`DiscoverableCredentialRequestOptions::mediation`] and + /// [`NonDiscoverableCredentialRequestOptions::mediation`]. + pub mediation: CredentialMediationRequirement, + /// See [`DiscoverableCredentialRequestOptions::public_key`] and + /// See [`NonDiscoverableCredentialRequestOptions::options`]. + pub public_key: PublicKeyCredentialRequestOptionsOwned, +} +impl ClientCredentialRequestOptions { + /// Creates a `DiscoverableCredentialRequestOptions` based on the contained data where + /// [`DiscoverableCredentialRequestOptions::public_key`] is constructed via + /// [`PublicKeyCredentialRequestOptionsOwned::into_options`]. + #[inline] + #[must_use] + pub fn into_discoverable_options(&self) -> DiscoverableCredentialRequestOptions<'_, '_, '_> { + DiscoverableCredentialRequestOptions { + mediation: self.mediation, + public_key: self.public_key.into_options(), + } + } + /// Creates a `NonDiscoverableCredentialRequestOptions` based on the contained data where + /// [`NonDiscoverableCredentialRequestOptions::options`] is constructed via + /// [`PublicKeyCredentialRequestOptionsOwned::into_options`]. + #[inline] + #[must_use] + pub fn into_non_discoverable_options( + &self, + allow_credentials: AllowedCredentials, + ) -> NonDiscoverableCredentialRequestOptions<'_, '_, '_> { + NonDiscoverableCredentialRequestOptions { + mediation: self.mediation, + options: self.public_key.into_options(), + allow_credentials, + } + } +} +impl Default for ClientCredentialRequestOptions { + #[inline] + fn default() -> Self { + Self { + mediation: CredentialMediationRequirement::default(), + public_key: PublicKeyCredentialRequestOptionsOwned::default(), + } + } +} +impl<'de> Deserialize<'de> for ClientCredentialRequestOptions { + /// Deserializes a `struct` according to the following pseudo-schema: + /// + /// ```json + /// { + /// "mediation": null | "required" | "conditional", + /// "publicKey": null | <PublicKeyCredentialRequestOptionsOwned> + /// } + /// ``` + /// + /// where none of the fields are required and `"publicKey"` is deserialized according to + /// [`PublicKeyCredentialRequestOptionsOwned::deserialize`]. If any field is missing or is `null`, then + /// the corresponding [`Default`] `impl` will be used. + /// + /// Unknown or duplicate fields lead to an error. + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `ClientCredentialRequestOptions`. + struct ClientCredentialRequestOptionsVisitor; + impl<'d> Visitor<'d> for ClientCredentialRequestOptionsVisitor { + type Value = ClientCredentialRequestOptions; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("ClientCredentialRequestOptions") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field in `ClientCredentialRequestOptions`. + enum Field { + /// `mediation`. + Mediation, + /// `publicKey` + PublicKey, + } + 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, "'{MEDIATION}' or '{PUBLIC_KEY}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + MEDIATION => Ok(Field::Mediation), + PUBLIC_KEY => Ok(Field::PublicKey), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut med = None; + let mut key = None; + while let Some(k) = map.next_key()? { + match k { + Field::Mediation => { + if med.is_some() { + return Err(Error::duplicate_field(MEDIATION)); + } + med = map.next_value::<Option<_>>().map(Some)?; + } + Field::PublicKey => { + if key.is_some() { + return Err(Error::duplicate_field(PUBLIC_KEY)); + } + key = map.next_value::<Option<_>>().map(Some)?; + } + } + } + Ok(ClientCredentialRequestOptions { + mediation: med.flatten().unwrap_or_default(), + public_key: key.flatten().unwrap_or_default(), + }) + } + } + /// Fields for `ClientCredentialRequestOptions`. + const FIELDS: &[&str; 2] = &[MEDIATION, PUBLIC_KEY]; + deserializer.deserialize_struct( + "ClientCredentialRequestOptions", + FIELDS, + ClientCredentialRequestOptionsVisitor, + ) + } +} +#[cfg(test)] +mod test { + use super::{ + super::ExtensionReq, ClientCredentialRequestOptions, CredentialMediationRequirement, + DEFAULT_RP_ID, ExtensionOwned, FIVE_MINUTES, Hint, NonZeroU32, + PublicKeyCredentialRequestOptionsOwned, UserVerificationRequirement, + }; + use serde_json::Error; + #[test] + fn client_options() -> Result<(), Error> { + let mut err = + serde_json::from_str::<ClientCredentialRequestOptions>(r#"{"bob":true}"#).unwrap_err(); + assert_eq!( + err.to_string()[..56], + *"unknown field `bob`, expected `mediation` or `publicKey`" + ); + err = serde_json::from_str::<ClientCredentialRequestOptions>( + r#"{"mediation":"required","mediation":"required"}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..27], *"duplicate field `mediation`"); + let mut options = serde_json::from_str::<ClientCredentialRequestOptions>(r#"{}"#)?; + assert!(matches!( + options.mediation, + CredentialMediationRequirement::Required + )); + assert_eq!(options.public_key.rp_id, DEFAULT_RP_ID); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!(matches!( + options.public_key.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(matches!(options.public_key.hints, Hint::None)); + assert!(options.public_key.extensions.prf.is_none()); + options = serde_json::from_str::<ClientCredentialRequestOptions>( + r#"{"mediation":null,"publicKey":null}"#, + )?; + assert!(matches!( + options.mediation, + CredentialMediationRequirement::Required + )); + assert_eq!(options.public_key.rp_id, DEFAULT_RP_ID); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!(matches!( + options.public_key.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(matches!(options.public_key.hints, Hint::None)); + assert!(options.public_key.extensions.prf.is_none()); + options = serde_json::from_str::<ClientCredentialRequestOptions>(r#"{"publicKey":{}}"#)?; + assert_eq!(options.public_key.rp_id, DEFAULT_RP_ID); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!(matches!( + options.public_key.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(matches!(options.public_key.hints, Hint::None)); + assert!(options.public_key.extensions.prf.is_none()); + options = serde_json::from_str::<ClientCredentialRequestOptions>( + r#"{"mediation":"conditional","publicKey":{"rpId":"example.com","timeout":300000,"allowCredentials":[],"userVerification":"required","extensions":{"prf":{"eval":{"first":"","second":""}}},"hints":["security-key"],"challenge":null}}"#, + )?; + assert!(matches!( + options.mediation, + CredentialMediationRequirement::Conditional + )); + assert_eq!(options.public_key.rp_id.as_ref(), "example.com"); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!(matches!( + options.public_key.user_verification, + UserVerificationRequirement::Required + )); + assert!( + options + .public_key + .extensions + .prf + .map_or(false, |prf| prf.first.is_empty() + && prf.second.is_some_and(|p| p.is_empty()) + && matches!(prf.ext_req, ExtensionReq::Allow)) + ); + Ok(()) + } + #[test] + fn key_options() -> Result<(), Error> { + let mut err = + serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>(r#"{"bob":true}"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..130], + *"unknown field `bob`, expected one of `rpId`, `userVerification`, `challenge`, `timeout`, `allowCredentials`, `hints`, `extensions`" + ); + err = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"rpId":"example.com","rpId":"example.com"}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..22], *"duplicate field `rpId`"); + err = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"challenge":"AAAAAAAAAAAAAAAAAAAAAA"}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..41], + *"invalid type: Option value, expected null" + ); + err = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"allowCredentials":[{"type":"public-key","transports":["usb"],"id":"AAAAAAAAAAAAAAAAAAAAAA"}]}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..19], *"trailing characters"); + err = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>(r#"{"timeout":0}"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..50], + *"invalid value: integer `0`, expected a nonzero u32" + ); + err = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"timeout":4294967296}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..59], + *"invalid value: integer `4294967296`, expected a nonzero u32" + ); + let mut key = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>(r#"{}"#)?; + assert_eq!(key.rp_id, DEFAULT_RP_ID); + assert_eq!(key.timeout, FIVE_MINUTES); + assert!(matches!( + key.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(key.extensions.prf.is_none()); + assert!(matches!(key.hints, Hint::None)); + key = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"rpId":null,"timeout":null,"allowCredentials":null,"userVerification":null,"extensions":null,"hints":null,"challenge":null}"#, + )?; + assert_eq!(key.rp_id, DEFAULT_RP_ID); + assert_eq!(key.timeout, FIVE_MINUTES); + assert!(matches!( + key.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(key.extensions.prf.is_none()); + assert!(matches!(key.hints, Hint::None)); + key = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"allowCredentials":[],"extensions":{},"hints":[]}"#, + )?; + assert!(matches!( + key.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(matches!(key.hints, Hint::None)); + assert!(key.extensions.prf.is_none()); + key = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"extensions":{"prf":null}}"#, + )?; + assert!(key.extensions.prf.is_none()); + key = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"rpId":"example.com","timeout":300000,"allowCredentials":[],"userVerification":"required","extensions":{"prf":{"eval":{"first":"","second":""}}},"hints":["security-key"],"challenge":null}"#, + )?; + assert_eq!(key.rp_id.as_ref(), "example.com"); + assert_eq!(key.timeout, FIVE_MINUTES); + assert!(matches!( + key.user_verification, + UserVerificationRequirement::Required + )); + assert!(matches!(key.hints, Hint::SecurityKey)); + assert!(key.extensions.prf.map_or(false, |prf| prf.first.is_empty() + && prf.second.is_some_and(|p| p.is_empty()) + && matches!(prf.ext_req, ExtensionReq::Allow))); + key = serde_json::from_str::<PublicKeyCredentialRequestOptionsOwned>( + r#"{"timeout":4294967295}"#, + )?; + assert_eq!(key.timeout, NonZeroU32::MAX); + Ok(()) + } + #[test] + fn extension() -> Result<(), Error> { + let mut err = serde_json::from_str::<ExtensionOwned>(r#"{"bob":true}"#).unwrap_err(); + assert_eq!( + err.to_string()[..35], + *"unknown field `bob`, expected `prf`" + ); + err = serde_json::from_str::<ExtensionOwned>( + r#"{"prf":{"eval":{"first":"","second":""}},"prf":{"eval":{"first":"","second":""}}}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..21], *"duplicate field `prf`"); + err = serde_json::from_str::<ExtensionOwned>(r#"{"prf":{"eval":{"first":null}}}"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..51], + *"invalid type: null, expected base64url-encoded data" + ); + let mut ext = + serde_json::from_str::<ExtensionOwned>(r#"{"prf":{"eval":{"first":"","second":""}}}"#)?; + assert!(ext.prf.map_or(false, |prf| prf.first.is_empty() + && prf.second.is_some_and(|v| v.is_empty()) + && matches!(prf.ext_req, ExtensionReq::Allow))); + ext = serde_json::from_str::<ExtensionOwned>(r#"{"prf":null}"#)?; + assert!(ext.prf.is_none()); + ext = serde_json::from_str::<ExtensionOwned>(r#"{}"#)?; + assert!(ext.prf.is_none()); + Ok(()) + } +} diff --git a/src/request/auth/ser_server_state.rs b/src/request/auth/ser_server_state.rs @@ -241,7 +241,7 @@ impl<'a> DecodeBuffer<'a> for AuthenticationServerState { } } /// Error returned from [`DiscoverableAuthenticationServerState::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeDiscoverableAuthenticationServerStateErr { /// Variant returned when there was trailing data after decoding a [`DiscoverableAuthenticationServerState`]. TrailingData, @@ -277,7 +277,7 @@ impl Decode for DiscoverableAuthenticationServerState { } } /// Error returned from [`NonDiscoverableAuthenticationServerState::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeNonDiscoverableAuthenticationServerStateErr { /// Variant returned when there was trailing data after decoding a [`NonDiscoverableAuthenticationServerState`]. TrailingData, diff --git a/src/request/error.rs b/src/request/error.rs @@ -8,7 +8,7 @@ use core::{ num::ParseIntError, }; /// Error returned by [`AsciiDomain::try_from`] when the `Vec` is not a valid ASCII domain. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AsciiDomainErr { /// Variant returned when the domain is empty. Empty, @@ -39,7 +39,7 @@ impl Display for AsciiDomainErr { impl Error for AsciiDomainErr {} /// Error returned by [`Url::from_str`] when the `str` passed to the /// [URL serializer](https://url.spec.whatwg.org/#concept-url-serializer) leads to a failure. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct UrlErr; impl Display for UrlErr { #[inline] @@ -48,8 +48,18 @@ impl Display for UrlErr { } } impl Error for UrlErr {} +/// Error returned by [`RpId::try_from`] when the `String` is not a valid [`AsciiDomain`] nor [`Url`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RpIdErr; +impl Display for RpIdErr { + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("RpId is invalid") + } +} +impl Error for RpIdErr {} /// Error returned by [`Scheme::try_from`] when the passed [`str`] is empty. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SchemeParseErr; impl Display for SchemeParseErr { #[inline] @@ -60,7 +70,7 @@ impl Display for SchemeParseErr { impl Error for SchemeParseErr {} /// Error returned by [`Port::from_str`] when the passed [`str`] is not a valid unsigned 16-bit integer in /// decimal form without leading 0s. -#[derive(Debug)] +#[derive(Debug, Eq, PartialEq)] pub enum PortParseErr { /// Variant returned iff [`u16::from_str`] does. ParseInt(ParseIntError), @@ -80,7 +90,7 @@ impl Display for PortParseErr { } impl Error for PortParseErr {} /// Error returned by [`DomainOrigin::try_from`]. -#[derive(Debug)] +#[derive(Debug, Eq, PartialEq)] pub enum DomainOriginParseErr { /// Variant returned when there is an error parsing the scheme. Scheme(SchemeParseErr), diff --git a/src/request/register.rs b/src/request/register.rs @@ -31,6 +31,7 @@ use core::{ cmp::Ordering, fmt::{self, Display, Formatter}, hash::{Hash, Hasher}, + mem, num::{NonZeroU32, NonZeroU64}, time::Duration, }; @@ -49,17 +50,17 @@ pub mod bin; mod custom; /// Contains error types. pub mod error; -/// Contains functionality to serialize data to a client. +/// Contains functionality to (de)serialize data to a client. #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] #[cfg(feature = "serde")] -pub(crate) mod ser; +pub mod ser; /// Contains functionality to (de)serialize [`RegistrationServerState`] to a data store. #[cfg_attr(docsrs, doc(cfg(feature = "serializable_server_state")))] #[cfg(feature = "serializable_server_state")] pub mod ser_server_state; /// Used by [`Extension::cred_protect`] to enforce the [`CredentialProtectionPolicy`] sent by the client via /// [`Registration`]. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum CredProtect { /// No `credProtect` request. #[default] @@ -169,23 +170,6 @@ impl CredProtect { } } } -#[cfg(test)] -impl PartialEq for CredProtect { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::None => matches!(other, Self::None), - Self::UserVerificationOptional(enforce, info) => { - matches!(*other, Self::UserVerificationOptional(enforce2, info2) if enforce == enforce2 && info == info2) - } - Self::UserVerificationOptionalWithCredentialIdList(enforce, info) => { - matches!(*other, Self::UserVerificationOptionalWithCredentialIdList(enforce2, info2) if enforce == enforce2 && info == info2) - } - Self::UserVerificationRequired(enforce, info) => { - matches!(*other, Self::UserVerificationRequired(enforce2, info2) if enforce == enforce2 && info == info2) - } - } - } -} impl Display for CredProtect { #[inline] fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { @@ -428,6 +412,12 @@ impl<'a> Username<'a> { pub fn with_max_len<'b: 'a>(value: Cow<'b, str>) -> Result<Self, UsernameErr> { Self::try_from(value) } + /// Returns `Self` containing `"blank"`. + #[expect(clippy::unreachable, reason = "want to crash when there is a bug")] + fn blank() -> Self { + Self::try_from("blank") + .unwrap_or_else(|_e| unreachable!("'blank' is no longer a valid Username")) + } } impl AsRef<str> for Username<'_> { #[inline] @@ -593,7 +583,7 @@ impl PartialEq<CoseAlgorithmIdentifier> for &CoseAlgorithmIdentifier { } } /// Non-empty ordered set of [`CoseAlgorithmIdentifier`]s. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CoseAlgorithmIdentifiers(u8); impl CoseAlgorithmIdentifiers { /// Contains all [`CoseAlgorithmIdentifier`]s. @@ -646,61 +636,177 @@ impl Default for CoseAlgorithmIdentifiers { Self::ALL } } -#[cfg(test)] -impl PartialEq for CoseAlgorithmIdentifiers { - fn eq(&self, other: &Self) -> bool { - self.0 == other.0 - } -} -/// `newtype` of `u8` bound inclusively between [`Self::MIN`] and [`Self::MAX`]. +/// Four to sixty-three inclusively. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct FourToSixtyThree(u8); +#[repr(u8)] +pub enum FourToSixtyThree { + /// 4. + Four = 4, + /// 5. + Five, + /// 6. + Six, + /// 7. + Seven, + /// 8. + Eight, + /// 9. + Nine, + /// 10. + Ten, + /// 11. + Eleven, + /// 12. + Twelve, + /// 13. + Thirteen, + /// 14. + Fourteen, + /// 15. + Fifteen, + /// 16. + Sixteen, + /// 17. + Seventeen, + /// 18. + Eighteen, + /// 19. + Nineteen, + /// 20. + Twenty, + /// 21. + TwentyOne, + /// 22. + TwentyTwo, + /// 23. + TwentyThree, + /// 24. + TwentyFour, + /// 25. + TwentyFive, + /// 26. + TwentySix, + /// 27. + TwentySeven, + /// 28. + TwentyEight, + /// 29. + TwentyNine, + /// 30. + Thirty, + /// 31. + ThirtyOne, + /// 32. + ThirtyTwo, + /// 33. + ThirtyThree, + /// 34. + ThirtyFour, + /// 35. + ThirtyFive, + /// 36. + ThirtySix, + /// 37. + ThirtySeven, + /// 38. + ThirtyEight, + /// 39. + ThirtyNine, + /// 40. + Fourty, + /// 41. + FourtyOne, + /// 42. + FourtyTwo, + /// 43. + FourtyThree, + /// 44. + FourtyFour, + /// 45. + FourtyFive, + /// 46. + FourtySix, + /// 47. + FourtySeven, + /// 48. + FourtyEight, + /// 49. + FourtyNine, + /// 50. + Fifty, + /// 51. + FiftyOne, + /// 52. + FiftyTwo, + /// 53. + FiftyThree, + /// 54. + FiftyFour, + /// 55. + FiftyFive, + /// 56. + FiftySix, + /// 57. + FiftySeven, + /// 58. + FiftyEight, + /// 59. + FiftyNine, + /// 60. + Sixty, + /// 61. + SixtyOne, + /// 62. + SixtyTwo, + /// 63. + SixtyThree, +} impl FourToSixtyThree { - /// Minimum inner value. - const MIN_INNER: u8 = 4; - /// Maximum inner value. - const MAX_INNER: u8 = 63; - /// Minimum value. - pub const MIN: Self = Self(Self::MIN_INNER); - /// Maximum value. - pub const MAX: Self = Self(Self::MAX_INNER); - /// Returns `Self` iff `val` is inclusively between [`Self::MIN`] and [`Self::MAX`]. + /// Returns the equivalent `u8`. + #[expect(clippy::as_conversions, reason = "comment justifies correctness")] #[inline] #[must_use] - pub const fn new(val: u8) -> Option<Self> { - match val { - Self::MIN_INNER..=Self::MAX_INNER => Some(Self(val)), - _ => None, - } + pub const fn into_u8(self) -> u8 { + // This is correct since `Self` is `repr(u8)`, and the initial discriminant has the value `4` + // and subsequent discriminants are implicitly incremented by 1. + self as u8 } - /// Returns the contained value. + /// Returns `Some` representing `val` iff `val` is inclusively between 4 and 63. + #[expect(unsafe_code, reason = "comment justifies correctness")] #[inline] #[must_use] - pub const fn value(self) -> u8 { - self.0 + pub const fn from_u8(val: u8) -> Option<Self> { + match val { + 0..=3 | 64.. => None, + _ => { + // SAFETY: + // `val` is inclusively between 4 and 63, and `Self` is `repr(u8)`; thus this + // is safe and correct. + Some(unsafe { mem::transmute::<u8, Self>(val) }) + } + } } } impl Display for FourToSixtyThree { #[inline] fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - self.0.fmt(f) + self.into_u8().fmt(f) } } impl From<FourToSixtyThree> for u8 { #[inline] fn from(value: FourToSixtyThree) -> Self { - value.0 + value.into_u8() } } impl Default for FourToSixtyThree { - /// Returns [`Self::MIN`]. #[inline] fn default() -> Self { - Self::MIN + Self::Four } } /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug)] pub struct Extension<'prf_first, 'prf_second> { /// [`credProps`](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension). /// @@ -753,6 +859,66 @@ pub struct Extension<'prf_first, 'prf_second> { /// the inputs and have unique values for each credential. pub prf: Option<(PrfInput<'prf_first, 'prf_second>, ExtensionInfo)>, } +impl<'prf_first, 'prf_second> Extension<'prf_first, 'prf_second> { + /// Returns an empty `Extension`. + #[inline] + #[must_use] + pub const fn none() -> Self { + Self { + cred_props: None, + cred_protect: CredProtect::None, + min_pin_length: None, + prf: None, + } + } + /// Same as [`Self::none`] except [`Self::cred_props`] is `Some` containing `req`. + #[inline] + #[must_use] + pub const fn with_cred_props(req: ExtensionReq) -> Self { + Self { + cred_props: Some(req), + ..Self::none() + } + } + /// Same as [`Self::none`] except [`Self::cred_protect`] is `cred_protect`. + #[inline] + #[must_use] + pub const fn with_cred_protect(cred_protect: CredProtect) -> Self { + Self { + cred_protect, + ..Self::none() + } + } + /// Same as [`Self::none`] except [`Self::min_pin_length`] is `Some` containing `min_len` and `info`. + #[inline] + #[must_use] + pub const fn with_min_pin_length(min_len: FourToSixtyThree, info: ExtensionInfo) -> Self { + Self { + min_pin_length: Some((min_len, info)), + ..Self::none() + } + } + /// Same as [`Self::none`] except [`Self::prf`] is `Some` containing `input` and `info`. + #[expect(single_use_lifetimes, reason = "false positive")] + #[inline] + #[must_use] + pub const fn with_prf<'a: 'prf_first, 'b: 'prf_second>( + input: PrfInput<'a, 'b>, + info: ExtensionInfo, + ) -> Self { + Self { + prf: Some((input, info)), + ..Self::none() + } + } +} +impl Default for Extension<'_, '_> { + /// Same as [`Self::none`]. + #[inline] + fn default() -> Self { + Self::none() + } +} #[cfg(test)] impl PartialEq for Extension<'_, '_> { fn eq(&self, other: &Self) -> bool { @@ -987,12 +1153,10 @@ impl<'a: 'b, 'b, const LEN: usize> From<&'a UserHandle<LEN>> /// assert!(entity.display_name.is_none()); /// # Ok::<_, webauthn_rp::AggErr>(()) /// ``` - #[expect(clippy::unreachable, reason = "we want to crash when there is a bug")] #[inline] fn from(value: &'a UserHandle<LEN>) -> Self { Self { - name: Username::try_from("blank") - .unwrap_or_else(|_e| unreachable!("'blank' is no longer a valid Username")), + name: Username::blank(), id: value, display_name: None, } @@ -1005,7 +1169,7 @@ pub type PublicKeyCredentialUserEntity64<'name, 'display_name, 'id> = pub type PublicKeyCredentialUserEntity16<'name, 'display_name, 'id> = PublicKeyCredentialUserEntity<'name, 'display_name, 'id, 16>; /// [`ResidentKeyRequirement`](https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement) sent to the client. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ResidentKeyRequirement { /// [`required`](https://www.w3.org/TR/webauthn-3/#dom-residentkeyrequirement-required). Required, @@ -1014,19 +1178,9 @@ pub enum ResidentKeyRequirement { /// [`preferred`](https://www.w3.org/TR/webauthn-3/#dom-residentkeyrequirement-preferred). Preferred, } -#[cfg(test)] -impl PartialEq for ResidentKeyRequirement { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::Required => matches!(other, Self::Required), - Self::Discouraged => matches!(other, Self::Discouraged), - Self::Preferred => matches!(other, Self::Preferred), - } - } -} /// [`PublicKeyCredentialHints`](https://www.w3.org/TR/webauthn-3/#enumdef-publickeycredentialhint) /// for [`AuthenticatorAttachment::CrossPlatform`] authenticators. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum CrossPlatformHint { /// No hints. #[default] @@ -1052,21 +1206,9 @@ impl From<CrossPlatformHint> for Hint { } } } -#[cfg(test)] -impl PartialEq for CrossPlatformHint { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::None => matches!(other, Self::None), - Self::SecurityKey => matches!(other, Self::SecurityKey), - Self::Hybrid => matches!(other, Self::Hybrid), - Self::SecurityKeyHybrid => matches!(other, Self::SecurityKeyHybrid), - Self::HybridSecurityKey => matches!(other, Self::HybridSecurityKey), - } - } -} /// [`PublicKeyCredentialHints`](https://www.w3.org/TR/webauthn-3/#enumdef-publickeycredentialhint) /// for [`AuthenticatorAttachment::Platform`] authenticators. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum PlatformHint { /// No hints. #[default] @@ -1083,18 +1225,9 @@ impl From<PlatformHint> for Hint { } } } -#[cfg(test)] -impl PartialEq for PlatformHint { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::None => matches!(other, Self::None), - Self::ClientDevice => matches!(other, Self::ClientDevice), - } - } -} /// [`AuthenticatorAttachment`](https://www.w3.org/TR/webauthn-3/#enumdef-authenticatorattachment) /// requirement with associated hints for further refinement. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorAttachmentReq { /// No attachment information (i.e., any [`AuthenticatorAttachment`]). None(Hint), @@ -1155,18 +1288,6 @@ impl AuthenticatorAttachmentReq { } } } -#[cfg(test)] -impl PartialEq for AuthenticatorAttachmentReq { - fn eq(&self, other: &Self) -> bool { - match *self { - Self::None(info) => matches!(*other, Self::None(info2) if info == info2), - Self::Platform(info) => matches!(*other, Self::Platform(info2) if info == info2), - Self::CrossPlatform(info) => { - matches!(*other, Self::CrossPlatform(info2) if info == info2) - } - } - } -} /// [`AuthenticatorSelectionCriteria`](https://www.w3.org/TR/webauthn-3/#dictionary-authenticatorSelection). #[derive(Clone, Copy, Debug)] pub struct AuthenticatorSelectionCriteria { @@ -2072,7 +2193,7 @@ impl ServerExtensionInfo { } else { // Pretend to set `minPinLength`, so we can check `prf`. self.min_pin_length = - Some((FourToSixtyThree::MIN, ExtensionInfo::RequireEnforceValue)); + Some((FourToSixtyThree::Four, ExtensionInfo::RequireEnforceValue)); self.validate_unsolicited(client_ext, auth_ext) } } else if !matches!(auth_ext.cred_protect, CredentialProtectionPolicy::None) { @@ -2588,11 +2709,7 @@ mod tests { false, ExtensionInfo::RequireEnforceValue, ), - min_pin_length: Some(( - FourToSixtyThree::new(10) - .unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), - ExtensionInfo::RequireEnforceValue, - )), + min_pin_length: Some((FourToSixtyThree::Ten, ExtensionInfo::RequireEnforceValue)), prf: Some(( PrfInput { first: [0].as_slice(), @@ -2921,7 +3038,7 @@ mod tests { ); } _ = options.min_pin.map(|p| { - assert!(p.value() <= 23, "bug"); + assert!(p <= FourToSixtyThree::TwentyThree, "bug"); attestation_object.extend_from_slice( [ // CBOR text of length 12. @@ -2938,7 +3055,7 @@ mod tests { b'g', b't', b'h', - CBOR_UINT | p.value(), + CBOR_UINT | p.into_u8(), ] .as_slice(), ); @@ -3104,30 +3221,21 @@ mod tests { [None, Some(ExtensionReq::Require), Some(ExtensionReq::Allow)]; const ALL_PIN_OPTIONS: [Option<(FourToSixtyThree, ExtensionInfo)>; 5] = [ None, + Some((FourToSixtyThree::Five, ExtensionInfo::RequireEnforceValue)), Some(( - FourToSixtyThree::new(5).unwrap(), - ExtensionInfo::RequireEnforceValue, - )), - Some(( - FourToSixtyThree::new(5).unwrap(), + FourToSixtyThree::Five, ExtensionInfo::RequireDontEnforceValue, )), - Some(( - FourToSixtyThree::new(5).unwrap(), - ExtensionInfo::AllowEnforceValue, - )), - Some(( - FourToSixtyThree::new(5).unwrap(), - ExtensionInfo::AllowDontEnforceValue, - )), + Some((FourToSixtyThree::Five, ExtensionInfo::AllowEnforceValue)), + Some((FourToSixtyThree::Five, ExtensionInfo::AllowDontEnforceValue)), ]; const ALL_CRED_PROPS_OPTIONS: [Option<Option<bool>>; 4] = [None, Some(None), Some(Some(false)), Some(Some(true))]; const ALL_MIN_PIN_OPTIONS: [Option<FourToSixtyThree>; 4] = [ None, - Some(FourToSixtyThree::MIN), - Some(FourToSixtyThree::new(5).unwrap()), - Some(FourToSixtyThree::new(6).unwrap()), + Some(FourToSixtyThree::Four), + Some(FourToSixtyThree::Five), + Some(FourToSixtyThree::Six), ]; for cred_protect in ALL_CRED_PROTECTION_OPTIONS { for prf in ALL_PRF_OPTIONS { @@ -3234,30 +3342,21 @@ mod tests { ]; const ALL_PIN_OPTIONS: [Option<(FourToSixtyThree, ExtensionInfo)>; 5] = [ None, + Some((FourToSixtyThree::Five, ExtensionInfo::RequireEnforceValue)), Some(( - FourToSixtyThree::new(5).unwrap(), - ExtensionInfo::RequireEnforceValue, - )), - Some(( - FourToSixtyThree::new(5).unwrap(), + FourToSixtyThree::Five, ExtensionInfo::RequireDontEnforceValue, )), - Some(( - FourToSixtyThree::new(5).unwrap(), - ExtensionInfo::AllowEnforceValue, - )), - Some(( - FourToSixtyThree::new(5).unwrap(), - ExtensionInfo::AllowDontEnforceValue, - )), + Some((FourToSixtyThree::Five, ExtensionInfo::AllowEnforceValue)), + Some((FourToSixtyThree::Five, ExtensionInfo::AllowDontEnforceValue)), ]; const ALL_NON_EMPTY_CRED_PROPS_OPTIONS: [Option<Option<bool>>; 3] = [Some(None), Some(Some(false)), Some(Some(true))]; const ALL_MIN_PIN_OPTIONS: [Option<FourToSixtyThree>; 4] = [ None, - Some(FourToSixtyThree::MIN), - Some(FourToSixtyThree::new(5).unwrap()), - Some(FourToSixtyThree::new(6).unwrap()), + Some(FourToSixtyThree::Four), + Some(FourToSixtyThree::Five), + Some(FourToSixtyThree::Six), ]; for cred_protect in ALL_CRED_PROTECTION_OPTIONS { for prf in ALL_PRF_OPTIONS { diff --git a/src/request/register/bin.rs b/src/request/register/bin.rs @@ -43,7 +43,7 @@ impl Encode for Nickname<'_> { } } /// Error returned from [`Nickname::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeNicknameErr { /// Variant returned when the encoded data could not be decoded /// into a [`Nickname`]. @@ -94,7 +94,7 @@ impl Encode for Username<'_> { } } /// Error returned from [`Username::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeUsernameErr { /// Variant returned when the encoded data could not be decoded /// into a [`Username`]. diff --git a/src/request/register/error.rs b/src/request/register/error.rs @@ -11,7 +11,7 @@ use core::{ #[cfg(doc)] use std::time::{Instant, SystemTime}; /// Error returned by [`Nickname::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NicknameErr { /// Error returned when the [Nickname Enforcement rule](https://www.rfc-editor.org/rfc/rfc8266#section-2.3) /// fails. @@ -31,7 +31,7 @@ impl Display for NicknameErr { } impl Error for NicknameErr {} /// Error returned by [`Username::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum UsernameErr { /// Error returned when the /// [UsernameCasePreserved Enforcement rule](https://www.rfc-editor.org/rfc/rfc8265#section-3.4.3) fails. @@ -51,7 +51,7 @@ impl Display for UsernameErr { } impl Error for UsernameErr {} /// Error returned by [`CredentialCreationOptions::start_ceremony`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CreationOptionsErr { /// Error when [`Extension::cred_protect`] is [`CredProtect::UserVerificationRequired`] but [`AuthenticatorSelectionCriteria::user_verification`] is not /// [`UserVerificationRequirement::Required`]. diff --git a/src/request/register/ser.rs b/src/request/register/ser.rs @@ -1,22 +1,32 @@ extern crate alloc; use super::{ - AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, CoseAlgorithmIdentifier, - CoseAlgorithmIdentifiers, CredProtect, CredentialCreationOptions, CrossPlatformHint, Extension, - Hint, Nickname, PlatformHint, PrfInput, PublicKeyCredentialCreationOptions, + super::{ + super::response::ser::{Null, Type}, + auth::PrfInputOwned, + ser::{DEFAULT_RP_ID, PrfHelper}, + }, + AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, Challenge, CoseAlgorithmIdentifier, + CoseAlgorithmIdentifiers, CredProtect, CredentialCreationOptions, + CredentialMediationRequirement, CrossPlatformHint, Extension, ExtensionInfo, ExtensionReq, + FIVE_MINUTES, FourToSixtyThree, Hint, Nickname, PlatformHint, PrfInput, + PublicKeyCredentialCreationOptions, PublicKeyCredentialDescriptor, PublicKeyCredentialUserEntity, RegistrationClientState, ResidentKeyRequirement, RpId, - UserHandle, Username, + UserHandle, UserVerificationRequirement, Username, }; +#[cfg(doc)] +use crate::response::AuthenticatorAttachment; use alloc::borrow::Cow; #[cfg(doc)] use core::str::FromStr; use core::{ + convert, fmt::{self, Formatter}, marker::PhantomData, + num::NonZeroU32, str, }; -use data_encoding::BASE64URL_NOPAD; use serde::{ - de::{Deserialize, Deserializer, Error, Unexpected, Visitor}, + de::{Deserialize, Deserializer, Error, MapAccess, SeqAccess, Unexpected, Visitor}, ser::{Serialize, SerializeSeq as _, SerializeStruct as _, Serializer}, }; impl Serialize for Nickname<'_> { @@ -61,6 +71,12 @@ impl Serialize for Username<'_> { serializer.serialize_str(self.0.as_ref()) } } +/// `"type"` +const TYPE: &str = "type"; +/// `"public-key"` +const PUBLIC_KEY: &str = "public-key"; +/// `"alg"` +const ALG: &str = "alg"; /// [EdDSA](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) const EDDSA: i16 = -8i16; /// [ES256](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) @@ -80,9 +96,9 @@ impl Serialize for CoseAlgorithmIdentifier { serializer .serialize_struct("PublicKeyCredentialParameters", 2) .and_then(|mut ser| { - ser.serialize_field("type", "public-key").and_then(|()| { + ser.serialize_field(TYPE, PUBLIC_KEY).and_then(|()| { ser.serialize_field( - "alg", + ALG, &match *self { Self::Eddsa => EDDSA, Self::Es256 => ES256, @@ -204,9 +220,9 @@ impl Serialize for UserHandle<1> { where S: Serializer, { - serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str( + serializer.serialize_str(base64url_nopad::encode_buffer( self.0.as_slice(), - [0; crate::base64url_nopad_len(1)].as_mut_slice(), + [0; base64url_nopad::encode_len(1)].as_mut_slice(), )) } } @@ -224,7 +240,7 @@ impl Serialize for UserHandle<$x> { S: Serializer, { - serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str(self.0.as_slice(), [0; crate::base64url_nopad_len($x)].as_mut_slice())) + serializer.serialize_str(base64url_nopad::encode_buffer(self.0.as_slice(), [0; base64url_nopad::encode_len($x)].as_mut_slice())) } } )* @@ -236,6 +252,8 @@ user_serialize!( 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64 ); +/// `"displayName"`. +const DISPLAY_NAME: &str = "displayName"; impl<const LEN: usize> Serialize for PublicKeyCredentialUserEntity<'_, '_, '_, LEN> where UserHandle<LEN>: Serialize, @@ -284,7 +302,7 @@ where ser.serialize_field(NAME, &self.name).and_then(|()| { ser.serialize_field(ID, &self.id).and_then(|()| { ser.serialize_field( - "displayName", + DISPLAY_NAME, self.display_name.as_ref().map_or("", |val| val.as_ref()), ) .and_then(|()| ser.end()) @@ -293,6 +311,12 @@ where }) } } +/// `"required"` +const REQUIRED: &str = "required"; +/// `"discouraged"` +const DISCOURAGED: &str = "discouraged"; +/// `"preferred"` +const PREFERRED: &str = "preferred"; impl Serialize for ResidentKeyRequirement { /// Serializes `self` to conform with /// [`ResidentKeyRequirement`](https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement). @@ -321,9 +345,9 @@ impl Serialize for ResidentKeyRequirement { S: Serializer, { serializer.serialize_str(match *self { - Self::Required => "required", - Self::Discouraged => "discouraged", - Self::Preferred => "preferred", + Self::Required => REQUIRED, + Self::Discouraged => DISCOURAGED, + Self::Preferred => PREFERRED, }) } } @@ -391,6 +415,18 @@ impl Serialize for PlatformHint { Hint::from(*self).serialize(serializer) } } +/// `"platform"`. +const PLATFORM: &str = "platform"; +/// `"cross-platform"`. +const CROSS_PLATFORM: &str = "cross-platform"; +/// `"authenticatorAttachment"`. +const AUTHENTICATOR_ATTACHMENT: &str = "authenticatorAttachment"; +/// `"residentKey"`. +const RESIDENT_KEY: &str = "residentKey"; +/// `"requireResidentKey"`. +const REQUIRE_RESIDENT_KEY: &str = "requireResidentKey"; +/// `"userVerification"`. +const USER_VERIFICATION: &str = "userVerification"; impl Serialize for AuthenticatorSelectionCriteria { /// Serializes `self` to conform with /// [`AuthenticatorSelectionCriteria`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria). @@ -437,26 +473,26 @@ impl Serialize for AuthenticatorSelectionCriteria { Ok(()) } else { ser.serialize_field( - "authenticatorAttachment", + AUTHENTICATOR_ATTACHMENT, if matches!( self.authenticator_attachment, AuthenticatorAttachmentReq::Platform(_) ) { - "platform" + PLATFORM } else { - "cross-platform" + CROSS_PLATFORM }, ) } .and_then(|()| { - ser.serialize_field("residentKey", &self.resident_key) + ser.serialize_field(RESIDENT_KEY, &self.resident_key) .and_then(|()| { ser.serialize_field( - "requireResidentKey", + REQUIRE_RESIDENT_KEY, &matches!(self.resident_key, ResidentKeyRequirement::Required), ) .and_then(|()| { - ser.serialize_field("userVerification", &self.user_verification) + ser.serialize_field(USER_VERIFICATION, &self.user_verification) .and_then(|()| ser.end()) }) }) @@ -478,6 +514,23 @@ impl Serialize for Prf<'_, '_> { }) } } +/// `credProps` key name. +const CRED_PROPS: &str = "credProps"; +/// `minPinLength` key name. +const MIN_PIN_LENGTH: &str = "minPinLength"; +/// `prf` key name. +const PRF: &str = "prf"; +/// `credentialProtectionPolicy` key name. +const CREDENTIAL_PROTECTION_POLICY: &str = "credentialProtectionPolicy"; +/// `enforceCredentialProtectionPolicy` key name. +const ENFORCE_CREDENTIAL_PROTECTION_POLICY: &str = "enforceCredentialProtectionPolicy"; +/// `"userVerificationOptional"`. +const USER_VERIFICATION_OPTIONAL: &str = "userVerificationOptional"; +/// `"userVerificationOptionalWithCredentialIDList"`. +const USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST: &str = + "userVerificationOptionalWithCredentialIDList"; +/// `"userVerificationRequired"`. +const USER_VERIFICATION_REQUIRED: &str = "userVerificationRequired"; impl Serialize for Extension<'_, '_> { /// Serializes `self` to conform with /// [`AuthenticationExtensionsClientInputsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsclientinputsjson). @@ -494,7 +547,7 @@ impl Serialize for Extension<'_, '_> { /// serde_json::to_string(&Extension { /// cred_props: Some(ExtensionReq::Allow), /// cred_protect: CredProtect::UserVerificationRequired(false, ExtensionInfo::RequireEnforceValue), - /// min_pin_length: Some((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::AllowDontEnforceValue)), + /// min_pin_length: Some((FourToSixtyThree::Sixteen, ExtensionInfo::AllowDontEnforceValue)), /// prf: Some((PrfInput { first: [0].as_slice(), second: None, }, ExtensionInfo::AllowEnforceValue)) /// })?, /// r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{"eval":{"first":"AA"}}}"# @@ -511,12 +564,6 @@ impl Serialize for Extension<'_, '_> { where S: Serializer, { - /// `credProps` key name. - const CRED_PROPS: &str = "credProps"; - /// `minPinLength` key name. - const MIN_PIN_LENGTH: &str = "minPinLength"; - /// `prf` key name. - const PRF: &str = "prf"; // The max is 1 + 2 + 1 + 1 = 5, so overflow is no concern. let count = usize::from(self.cred_props.is_some()) + if matches!(self.cred_protect, CredProtect::None) { @@ -539,31 +586,31 @@ impl Serialize for Extension<'_, '_> { // [`credProtect`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-credProtect-extension) // is serialized by serializing its fields directly and not as a map of fields. ser.serialize_field( - "credentialProtectionPolicy", + CREDENTIAL_PROTECTION_POLICY, match self.cred_protect { CredProtect::None => unreachable!( "Extensions is incorrectly serializing credProtect" ), CredProtect::UserVerificationOptional(enforce, _) => { enforce_policy = enforce; - "userVerificationOptional" + USER_VERIFICATION_OPTIONAL } CredProtect::UserVerificationOptionalWithCredentialIdList( enforce, _, ) => { enforce_policy = enforce; - "userVerificationOptionalWithCredentialIDList" + USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST } CredProtect::UserVerificationRequired(enforce, _) => { enforce_policy = enforce; - "userVerificationRequired" + USER_VERIFICATION_REQUIRED } }, ) .and_then(|()| { ser.serialize_field( - "enforceCredentialProtectionPolicy", + ENFORCE_CREDENTIAL_PROTECTION_POLICY, &enforce_policy, ) }) @@ -583,6 +630,30 @@ impl Serialize for Extension<'_, '_> { }) } } +/// `"rp"` +const RP: &str = "rp"; +/// `"user"` +const USER: &str = "user"; +/// `"challenge"` +const CHALLENGE: &str = "challenge"; +/// `"pubKeyCredParams"` +const PUB_KEY_CRED_PARAMS: &str = "pubKeyCredParams"; +/// `"timeout"` +const TIMEOUT: &str = "timeout"; +/// `"excludeCredentials"` +const EXCLUDE_CREDENTIALS: &str = "excludeCredentials"; +/// `"authenticatorSelection"` +const AUTHENTICATOR_SELECTION: &str = "authenticatorSelection"; +/// `"hints"` +const HINTS: &str = "hints"; +/// `"attestation"` +const ATTESTATION: &str = "attestation"; +/// `"attestationFormats"` +const ATTESTATION_FORMATS: &str = "attestationFormats"; +/// `"extensions"` +const EXTENSIONS: &str = "extensions"; +/// "none". +const NONE: &str = "none"; impl<'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> Serialize for PublicKeyCredentialCreationOptions< '_, @@ -603,41 +674,39 @@ where where S: Serializer, { - /// "none". - const NONE: &str = "none"; serializer .serialize_struct("PublicKeyCredentialCreationOptions", 11) .and_then(|mut ser| { - ser.serialize_field("rp", &PublicKeyCredentialRpEntity(self.rp_id)) + ser.serialize_field(RP, &PublicKeyCredentialRpEntity(self.rp_id)) .and_then(|()| { - ser.serialize_field("user", &self.user).and_then(|()| { - ser.serialize_field("challenge", &self.challenge) + ser.serialize_field(USER, &self.user).and_then(|()| { + ser.serialize_field(CHALLENGE, &self.challenge) .and_then(|()| { ser.serialize_field( - "pubKeyCredParams", + PUB_KEY_CRED_PARAMS, &self.pub_key_cred_params, ) .and_then(|()| { - ser.serialize_field("timeout", &self.timeout).and_then( + ser.serialize_field(TIMEOUT, &self.timeout).and_then( |()| { ser.serialize_field( - "excludeCredentials", + EXCLUDE_CREDENTIALS, self.exclude_credentials.as_slice(), ) .and_then(|()| { ser.serialize_field( - "authenticatorSelection", + AUTHENTICATOR_SELECTION, &self.authenticator_selection, ) .and_then(|()| { - ser.serialize_field("hints", &match self.authenticator_selection.authenticator_attachment { + ser.serialize_field(HINTS, &match self.authenticator_selection.authenticator_attachment { AuthenticatorAttachmentReq::None(hint) => hint, AuthenticatorAttachmentReq::Platform(hint) => hint.into(), AuthenticatorAttachmentReq::CrossPlatform(hint) => hint.into(), }).and_then(|()| { - ser.serialize_field("attestation", NONE).and_then(|()| { - ser.serialize_field("attestationFormats", [NONE].as_slice()).and_then(|()| { - ser.serialize_field("extensions", &self.extensions).and_then(|()| ser.end()) + ser.serialize_field(ATTESTATION, NONE).and_then(|()| { + ser.serialize_field(ATTESTATION_FORMATS, [NONE].as_slice()).and_then(|()| { + ser.serialize_field(EXTENSIONS, &self.extensions).and_then(|()| ser.end()) }) }) }) @@ -652,6 +721,10 @@ where }) } } +/// `"mediation"`. +const MEDIATION: &str = "mediation"; +/// `"publicKey"`. +const PUBLIC_KEY_NO_HYPEN: &str = "publicKey"; impl< 'rp_id, 'user_name, @@ -695,9 +768,9 @@ where serializer .serialize_struct("CredentialCreationOptions", 2) .and_then(|mut ser| { - ser.serialize_field("mediation", &self.mediation) + ser.serialize_field(MEDIATION, &self.mediation) .and_then(|()| { - ser.serialize_field("publicKey", &self.public_key) + ser.serialize_field(PUBLIC_KEY_NO_HYPEN, &self.public_key) .and_then(|()| ser.end()) }) }) @@ -769,7 +842,7 @@ where /// let user_handle = UserHandle64::new(); /// let mut options = CredentialCreationOptions::passkey(&rp_id, PublicKeyCredentialUserEntity { name: "pierre.de.fermat".try_into()?, id: &user_handle, display_name: Some("Pierre de Fermat".try_into()?) }, creds); /// options.public_key.authenticator_selection.authenticator_attachment = AuthenticatorAttachmentReq::None(Hint::SecurityKey); - /// options.public_key.extensions.min_pin_length = Some((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::RequireEnforceValue)); + /// options.public_key.extensions.min_pin_length = Some((FourToSixtyThree::Sixteen, ExtensionInfo::RequireEnforceValue)); /// # #[cfg(all(feature = "bin", feature = "custom"))] /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState::serialize")); /// let json = serde_json::json!({ @@ -968,26 +1041,15 @@ where fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str("UserHandle") } - #[expect( - clippy::panic_in_result_fn, - reason = "we want to crash when there is a bug" - )] fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: Error, { - if crate::base64url_nopad_len(L) == v.len() { + if base64url_nopad::encode_len(L) == v.len() { let mut data = [0; L]; - BASE64URL_NOPAD - .decode_mut(v.as_bytes(), data.as_mut_slice()) - .map_err(|e| E::custom(e.error)) - .map(|len| { - assert_eq!( - len, L, - "there is a bug in data_encoding::BASE64URL_NOPAD::decode_mut" - ); - UserHandle(data) - }) + base64url_nopad::decode_buffer_exact(v.as_bytes(), data.as_mut_slice()) + .map_err(E::custom) + .map(|()| UserHandle(data)) } else { Err(E::invalid_value( Unexpected::Str(v), @@ -1051,3 +1113,2530 @@ impl<'de> Deserialize<'de> for CoseAlgorithmIdentifier { deserializer.deserialize_i16(CoseAlgorithmIdentifierVisitor) } } +/// Helper to deserialize `PublicKeyCredentialRpEntity` with an optional `RpId`. +/// +/// Used in [`ClientCredentialCreationOptions::deserialize`]. +struct PublicKeyCredentialRpEntityHelper(RpId); +impl<'de> Deserialize<'de> for PublicKeyCredentialRpEntityHelper { + /// Conforms to the following schema: + /// + /// ```json + /// { + /// "id": null | <RpId>, + /// "name": null | "" | <RpId> | <Nickname> + /// } + /// ``` + /// + /// None of the fields are required. + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `PublicKeyCredentialRpEntityHelper`. + struct PublicKeyCredentialRpEntityHelperVisitor; + impl<'d> Visitor<'d> for PublicKeyCredentialRpEntityHelperVisitor { + type Value = PublicKeyCredentialRpEntityHelper; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("PublicKeyCredentialRpEntityHelper") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `PublicKeyCredentialRpEntityHelper`. + enum Field { + /// `id`. + Id, + /// `name`. + Name, + } + 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, "'{ID}' or '{NAME}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + ID => Ok(Field::Id), + NAME => Ok(Field::Name), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + /// Helper to deserialize `name`. + struct Name; + impl<'e> Deserialize<'e> for Name { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + /// `Visitor` for `Name`. + struct NameVisitor; + impl Visitor<'_> for NameVisitor { + type Value = Name; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("RpId name") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + if v.is_empty() { + Ok(Name) + } else { + Nickname::try_from(v).map(|_n| Name).or_else(|_e| { + RpId::try_from(v.to_owned()) + .map_err(E::custom) + .map(|_r| Name) + }) + } + } + } + deserializer.deserialize_str(NameVisitor) + } + } + let mut id = None; + let mut name = false; + while let Some(key) = map.next_key()? { + match key { + Field::Id => { + if id.is_some() { + return Err(Error::duplicate_field(ID)); + } + id = map.next_value::<Option<_>>().map(Some)?; + } + Field::Name => { + if name { + return Err(Error::duplicate_field(NAME)); + } + name = map.next_value::<Option<Name>>().map(|_n| true)?; + } + } + } + Ok(PublicKeyCredentialRpEntityHelper( + id.flatten().unwrap_or(DEFAULT_RP_ID), + )) + } + } + /// Fields for `PublicKeyCredentialRpEntityHelper`. + const FIELDS: &[&str; 2] = &[ID, NAME]; + deserializer.deserialize_struct( + "PublicKeyCredentialRpEntityHelper", + FIELDS, + PublicKeyCredentialRpEntityHelperVisitor, + ) + } +} +/// Similar to [`PublicKeyCredentialUserEntity`] except the [`UserHandle`] is owned. +/// +/// This is primarily useful to assist [`ClientCredentialCreationOptions::deserialize`]. +#[derive(Debug)] +pub struct PublicKeyCredentialUserEntityOwned<'name, 'display_name, const LEN: usize> { + /// See [`PublicKeyCredentialUserEntity::name`]. + pub name: Username<'name>, + /// See [`PublicKeyCredentialUserEntity::id`]. + pub id: UserHandle<LEN>, + /// See [`PublicKeyCredentialUserEntity::display_name`]. + pub display_name: Option<Nickname<'display_name>>, +} +impl<'a: 'name + 'display_name + 'id, 'name, 'display_name, 'id, const LEN: usize> + From<&'a PublicKeyCredentialUserEntityOwned<'_, '_, LEN>> + for PublicKeyCredentialUserEntity<'name, 'display_name, 'id, LEN> +{ + #[inline] + fn from(value: &'a PublicKeyCredentialUserEntityOwned<'_, '_, LEN>) -> Self { + Self { + name: (&value.name).into(), + id: &value.id, + display_name: value.display_name.as_ref().map(Into::into), + } + } +} +impl<const LEN: usize> Default for PublicKeyCredentialUserEntityOwned<'_, '_, LEN> +where + UserHandle<LEN>: Default, +{ + #[inline] + fn default() -> Self { + Self { + name: Username::blank(), + id: UserHandle::default(), + display_name: None, + } + } +} +impl<'de: 'name + 'display_name, 'name, 'display_name, const LEN: usize> Deserialize<'de> + for PublicKeyCredentialUserEntityOwned<'name, 'display_name, LEN> +where + UserHandle<LEN>: Default, +{ + /// Deserializes a `struct` according to + /// [`PublicKeyCredentialUserEntityJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialuserentityjson). + /// + /// Note none of the fields are required and all of them are allowed to be `null`. + /// [`id`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentityjson-id) is deserialized + /// according to [`UserHandle::deserialize`], + /// [`name`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentityjson-name) is deserialized + /// according to [`Username::deserialize`], and + /// [`displayName`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentityjson-displayname) is + /// deserialized according to [`Nickname::deserialize`] where `""` is deserialized to `None` (since + /// blank strings are not valid `Nickname`s). + /// + /// In the event `id` does not exist, a randomly generated `UserHandle` will be used. In the event `name` + /// does not exist, `"blank"` will be used. In the event `displayName` does not exist, `None` will + /// be used. + /// + /// Unknown or duplicate fields lead to an error. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::ser::PublicKeyCredentialUserEntityOwned; + /// let val = serde_json::from_str::<PublicKeyCredentialUserEntityOwned<'_, '_, 16>>(r#"{"name":"paul.erdos","displayName":"Erdős Pál"}"#)?; + /// assert_eq!(val.name.as_ref(), "paul.erdos"); + /// assert_eq!(val.display_name.as_ref().map(|v| v.as_ref()), Some("Erdős Pál")); + /// assert_ne!(val.id.as_slice(), [0; 16]); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[expect(clippy::too_many_lines, reason = "122 is fine")] + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `PublicKeyCredentialUserEntityOwned`. + struct PublicKeyCredentialUserEntityOwnedVisitor<'a, 'b, const L: usize>( + PhantomData<fn() -> (&'a (), &'b ())>, + ); + impl<'d: 'a + 'b, 'a, 'b, const L: usize> Visitor<'d> + for PublicKeyCredentialUserEntityOwnedVisitor<'a, 'b, L> + where + UserHandle<L>: Default, + { + type Value = PublicKeyCredentialUserEntityOwned<'a, 'b, L>; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("PublicKeyCredentialUserEntityOwned") + } + #[expect(clippy::too_many_lines, reason = "102 is fine")] + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `PublicKeyCredentialUserEntityOwned`. + enum Field { + /// `id`. + Id, + /// `name`. + Name, + /// `displayName` + DisplayName, + } + 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, "'{ID}', '{NAME}', or '{DISPLAY_NAME}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + ID => Ok(Field::Id), + NAME => Ok(Field::Name), + DISPLAY_NAME => Ok(Field::DisplayName), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + /// Helper to deserialize `displayName`. + struct DisplayName<'e>(Option<Nickname<'e>>); + impl<'e: 'f, 'f> Deserialize<'e> for DisplayName<'f> { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + /// `Visitor` for `DisplayName`. + struct DisplayNameVisitor<'g>(PhantomData<fn() -> &'g ()>); + impl<'g: 'h, 'h> Visitor<'g> for DisplayNameVisitor<'h> { + type Value = DisplayName<'h>; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("User display name") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + if v.is_empty() { + Ok(DisplayName(None)) + } else { + Nickname::try_from(v).map_err(E::custom).map(|name| { + DisplayName(Some(Nickname(Cow::Owned(name.0.into_owned())))) + }) + } + } + fn visit_borrowed_str<E>(self, v: &'g str) -> Result<Self::Value, E> + where + E: Error, + { + if v.is_empty() { + Ok(DisplayName(None)) + } else { + Nickname::try_from(v) + .map_err(E::custom) + .map(|n| DisplayName(Some(n))) + } + } + } + deserializer.deserialize_str(DisplayNameVisitor(PhantomData)) + } + } + let mut user_handle = None; + let mut username = None; + let mut display = None; + while let Some(key) = map.next_key()? { + match key { + Field::Id => { + if user_handle.is_some() { + return Err(Error::duplicate_field(ID)); + } + user_handle = map.next_value::<Option<_>>().map(Some)?; + } + Field::Name => { + if username.is_some() { + return Err(Error::duplicate_field(NAME)); + } + username = map.next_value::<Option<_>>().map(Some)?; + } + Field::DisplayName => { + if display.is_some() { + return Err(Error::duplicate_field(DISPLAY_NAME)); + } + display = map + .next_value::<Option<DisplayName<'_>>>() + .map(|n| n.map_or_else(|| Some(None), |disp| Some(disp.0)))?; + } + } + } + Ok(PublicKeyCredentialUserEntityOwned { + id: user_handle.flatten().unwrap_or_default(), + name: username.flatten().unwrap_or_else(Username::blank), + display_name: display.flatten(), + }) + } + } + /// Fields for `PublicKeyCredentialUserEntityOwned`. + const FIELDS: &[&str; 3] = &[ID, NAME, DISPLAY_NAME]; + deserializer.deserialize_struct( + "PublicKeyCredentialUserEntityOwned", + FIELDS, + PublicKeyCredentialUserEntityOwnedVisitor(PhantomData), + ) + } +} +/// `newtype` around `CoseAlgorithmIdentifier`. +struct PubParam(CoseAlgorithmIdentifier); +impl<'de> Deserialize<'de> for PubParam { + /// Conforms to the following schema: + /// + /// ```json + /// { + /// "alg": <CoseAlgorithmIdentifier>, + /// "type": "public-key", + /// } + /// ``` + /// + /// `"alg"` is required. + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `PubParam`. + struct PubParamVisitor; + impl<'d> Visitor<'d> for PubParamVisitor { + type Value = PubParam; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("PubParam") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `PubParam`. + enum Field { + /// `"type"`. + Type, + /// `"alg"`. + Alg, + } + 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, "'{TYPE}' or '{ALG}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + TYPE => Ok(Field::Type), + ALG => Ok(Field::Alg), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut typ = false; + let mut alg = None; + while let Some(key) = map.next_key()? { + match key { + Field::Type => { + if typ { + return Err(Error::duplicate_field(TYPE)); + } + typ = map.next_value::<Type>().map(|_t| true)?; + } + Field::Alg => { + if alg.is_some() { + return Err(Error::duplicate_field(ALG)); + } + alg = map.next_value().map(Some)?; + } + } + } + alg.ok_or_else(|| Error::missing_field(ALG)).map(PubParam) + } + } + /// Fields for `PubParam`. + const FIELDS: &[&str; 2] = &[TYPE, ALG]; + deserializer.deserialize_struct("PubParam", FIELDS, PubParamVisitor) + } +} +impl<'de> Deserialize<'de> for CoseAlgorithmIdentifiers { + /// Deserializes a sequence based on + /// [`pubKeyCredParams`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-pubkeycredparams) + /// except [`type`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialparameters-type) is not required. + /// + /// Note the sequence of [`CoseAlgorithmIdentifier`]s MUST match [`CoseAlgorithmIdentifier::cmp`] or an + /// error will occur (e.g., if [`CoseAlgorithmIdentifier::Eddsa`] exists, then it must appear first). + /// + /// An empty sequence will be treated as [`Self::ALL`]. + /// + /// Unknown or duplicate fields lead to an error. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::CoseAlgorithmIdentifiers; + /// assert!(serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"[{"type":"public-key","alg":-8},{"type":"public-key","alg":-7},{"type":"public-key","alg":-35},{"type":"public-key","alg":-257}]"#).is_ok()); + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `CoseAlgorithmIdentifiers`. + struct CoseAlgorithmIdentifiersVisitor; + impl<'d> Visitor<'d> for CoseAlgorithmIdentifiersVisitor { + type Value = CoseAlgorithmIdentifiers; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("CoseAlgorithmIdentifiers") + } + #[expect(clippy::else_if_without_else, reason = "prefer it this way")] + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'d>, + { + let mut eddsa = false; + let mut es256 = false; + let mut es384 = false; + let mut rs256 = false; + while let Some(elem) = seq.next_element::<PubParam>()? { + match elem.0 { + CoseAlgorithmIdentifier::Eddsa => { + if eddsa { + return Err(Error::custom( + "pubKeyCredParams contained duplicate EdDSA values", + )); + } else if es256 || es384 || rs256 { + return Err(Error::custom( + "pubKeyCredParams contained EdDSA, but it wasn't the first value", + )); + } + eddsa = true; + } + CoseAlgorithmIdentifier::Es256 => { + if es256 { + return Err(Error::custom( + "pubKeyCredParams contained duplicate Es256 values", + )); + } else if es384 || rs256 { + return Err(Error::custom( + "pubKeyCredParams contained Es256, but it was preceded by Es384 or Rs256", + )); + } + es256 = true; + } + CoseAlgorithmIdentifier::Es384 => { + if es384 { + return Err(Error::custom( + "pubKeyCredParams contained duplicate Es384 values", + )); + } else if rs256 { + return Err(Error::custom( + "pubKeyCredParams contained Es384, but it was preceded by Rs256", + )); + } + es384 = true; + } + CoseAlgorithmIdentifier::Rs256 => { + if rs256 { + return Err(Error::custom( + "pubKeyCredParams contained duplicate Rs256 values", + )); + } + rs256 = true; + } + } + } + let mut algs = CoseAlgorithmIdentifiers(0); + if eddsa { + algs = algs.add(CoseAlgorithmIdentifier::Eddsa); + } + if es256 { + algs = algs.add(CoseAlgorithmIdentifier::Es256); + } + if es384 { + algs = algs.add(CoseAlgorithmIdentifier::Es384); + } + if rs256 { + algs = algs.add(CoseAlgorithmIdentifier::Rs256); + } + Ok(if algs.0 == 0 { + CoseAlgorithmIdentifiers::ALL + } else { + algs + }) + } + } + deserializer.deserialize_seq(CoseAlgorithmIdentifiersVisitor) + } +} +/// Helper for `UserVerificatonRequirement::deserialize` and [`ResidentKeyRequirement::deserialize`]. +enum Requirement { + /// Required. + Required, + /// Discouraged. + Discouraged, + /// Preferred. + Preferred, +} +impl<'de> Deserialize<'de> for Requirement { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `Requirement`. + struct RequirementVisitor; + impl Visitor<'_> for RequirementVisitor { + type Value = Requirement; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "'{REQUIRED}', '{DISCOURAGED}', or '{PREFERRED}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + REQUIRED => Ok(Requirement::Required), + DISCOURAGED => Ok(Requirement::Discouraged), + PREFERRED => Ok(Requirement::Preferred), + _ => Err(E::invalid_value( + Unexpected::Str(v), + &format!("'{REQUIRED}', '{DISCOURAGED}', or '{PREFERRED}'").as_str(), + )), + } + } + } + deserializer.deserialize_str(RequirementVisitor) + } +} +impl From<Requirement> for ResidentKeyRequirement { + #[inline] + fn from(value: Requirement) -> Self { + match value { + Requirement::Required => Self::Required, + Requirement::Discouraged => Self::Discouraged, + Requirement::Preferred => Self::Preferred, + } + } +} +impl From<Requirement> for UserVerificationRequirement { + #[inline] + fn from(value: Requirement) -> Self { + match value { + Requirement::Required => Self::Required, + Requirement::Discouraged => Self::Discouraged, + Requirement::Preferred => Self::Preferred, + } + } +} +impl<'de> Deserialize<'de> for ResidentKeyRequirement { + /// Deserializes [`prim@str`] based on + /// [`ResidentKeyRequirement`](https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::ResidentKeyRequirement; + /// assert!( + /// matches!( + /// serde_json::from_str(r#""required""#)?, + /// ResidentKeyRequirement::Required + /// ) + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Requirement::deserialize(deserializer).map(Self::from) + } +} +impl<'de> Deserialize<'de> for UserVerificationRequirement { + /// Deserializes [`prim@str`] based on + /// [`UserVerificationRequirement`](https://www.w3.org/TR/webauthn-3/#enumdef-userverificationrequirement). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::UserVerificationRequirement; + /// assert!( + /// matches!( + /// serde_json::from_str(r#""required""#)?, + /// UserVerificationRequirement::Required + /// ) + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + Requirement::deserialize(deserializer).map(Self::from) + } +} +impl<'de> Deserialize<'de> for AuthenticatorAttachmentReq { + /// Deserializes a [`prim@str`] according to + /// [`AuthenticatorAttachment`](https://www.w3.org/TR/webauthn-3/#enumdef-authenticatorattachment). + /// + /// Note the contained hint will be none (e.g., [`PlatformHint::None`]). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::{AuthenticatorAttachmentReq, PlatformHint}; + /// assert!(matches!( + /// serde_json::from_str(r#""platform""#)?, + /// AuthenticatorAttachmentReq::Platform(hint) if matches!(hint, PlatformHint::None) + /// )); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `AuthenticatorAttachmentReq`. + struct AuthenticatorAttachmentReqVisitor; + impl Visitor<'_> for AuthenticatorAttachmentReqVisitor { + type Value = AuthenticatorAttachmentReq; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "'{PLATFORM}' or '{CROSS_PLATFORM}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + PLATFORM => Ok(AuthenticatorAttachmentReq::Platform(PlatformHint::None)), + CROSS_PLATFORM => Ok(AuthenticatorAttachmentReq::CrossPlatform( + CrossPlatformHint::None, + )), + _ => Err(E::invalid_value( + Unexpected::Str(v), + &format!("'{PLATFORM}' or '{CROSS_PLATFORM}'").as_str(), + )), + } + } + } + deserializer.deserialize_str(AuthenticatorAttachmentReqVisitor) + } +} +impl<'de> Deserialize<'de> for AuthenticatorSelectionCriteria { + /// Deserializes a `struct` based on + /// [`AuthenticatorSelectionCriteria`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticatorselectioncriteria). + /// + /// Note that none of the fields are required, and all are allowed to be `null`. Additionally + /// [`residentKey`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-residentkey) and + /// [`requireResidentKey`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-requireresidentkey) + /// must be consistent (i.e., `requireResidentKey` iff `residentKey` is [`ResidentKeyRequirement::Required`]). + /// + /// `residentKey` defaults to [`ResidentKeyRequirement::Discouraged`] when it is `null` or does not exist + /// unless `requireResidentKey` is `true` in which case it is `ResidentKeyRequirement::Required`. + /// + /// [`userVerification`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification) + /// is [`UserVerificationRequirement::Preferred`] if it does not exist or is `null`. + /// + /// If + /// [`authenticatorAttachment`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-authenticatorattachment) + /// does not exist or is `null`, then [`AuthenticatorAttachmentReq::None`] will be used containing + /// [`Hint::None`]. + /// + /// Unknown or duplicate fields lead to an error. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::{Hint, register::{AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria}}; + /// assert!( + /// matches!( + /// serde_json::from_str::<AuthenticatorSelectionCriteria>(r#"{"authenticatorAttachment":null,"residentKey":"required","requireResidentKey":true,"userVerification":"required"}"#)?.authenticator_attachment, + /// AuthenticatorAttachmentReq::None(hints) if matches!(hints, Hint::None) + /// ) + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[expect(clippy::too_many_lines, reason = "144 isn't too bad")] + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `AuthenticatorSelectionCriteria`. + struct AuthenticatorSelectionCriteriaVisitor; + impl<'de> Visitor<'de> for AuthenticatorSelectionCriteriaVisitor { + type Value = AuthenticatorSelectionCriteria; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthenticatorSelectionCriteria") + } + #[expect(clippy::too_many_lines, reason = "121 isn't too bad")] + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'de>, + { + /// Field for `AuthenticatorSelectionCriteria`. + enum Field { + /// `"authenticatorAttachment"`. + AuthenticatorAttachment, + /// `"residentKey"`. + ResidentKey, + /// `"requireResidentKey"`. + RequireResidentKey, + /// `"userVerification"`. + UserVerification, + } + 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, + "'{AUTHENTICATOR_ATTACHMENT}', '{RESIDENT_KEY}', '{REQUIRE_RESIDENT_KEY}', or '{USER_VERIFICATION}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + AUTHENTICATOR_ATTACHMENT => Ok(Field::AuthenticatorAttachment), + RESIDENT_KEY => Ok(Field::ResidentKey), + REQUIRE_RESIDENT_KEY => Ok(Field::RequireResidentKey), + USER_VERIFICATION => Ok(Field::UserVerification), + _ => Err(Error::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut attach = None; + let mut res_key = None; + let mut res_req: Option<Option<bool>> = None; + let mut uv = None; + while let Some(key) = map.next_key()? { + match key { + Field::AuthenticatorAttachment => { + if attach.is_some() { + return Err(Error::duplicate_field(AUTHENTICATOR_ATTACHMENT)); + } + attach = map.next_value::<Option<_>>().map(Some)?; + } + Field::ResidentKey => { + if res_key.is_some() { + return Err(Error::duplicate_field(RESIDENT_KEY)); + } + res_key = map.next_value::<Option<_>>().and_then(|opt| { + opt.map_or(Ok(Some(None)), |res| res_req.map_or(Ok(Some(opt)), |req_opt| req_opt.map_or(Ok(Some(opt)), |req| { + match res { + ResidentKeyRequirement::Required => { + if req { + Ok(Some(opt)) + } else { + Err(Error::custom(format!("'{RESIDENT_KEY}' is '{REQUIRED}', but '{REQUIRE_RESIDENT_KEY}' is false"))) + } + } + ResidentKeyRequirement::Discouraged | ResidentKeyRequirement::Preferred => { + if req { + Err(Error::custom(format!("'{RESIDENT_KEY}' is not '{REQUIRED}', but '{REQUIRE_RESIDENT_KEY}' is true"))) + } else { + Ok(Some(opt)) + } + } + } + }))) + })?; + } + Field::RequireResidentKey => { + if res_req.is_some() { + return Err(Error::duplicate_field(REQUIRE_RESIDENT_KEY)); + } + res_req = map.next_value::<Option<_>>().and_then(|opt| { + opt.map_or(Ok(Some(None)), |req| res_key.map_or(Ok(Some(opt)), |req_opt| req_opt.map_or(Ok(Some(opt)), |res| { + match res { + ResidentKeyRequirement::Required => { + if req { + Ok(Some(opt)) + } else { + Err(Error::custom(format!("'{RESIDENT_KEY}' is '{REQUIRED}', but '{REQUIRE_RESIDENT_KEY}' is false"))) + } + } + ResidentKeyRequirement::Discouraged | ResidentKeyRequirement::Preferred => { + if req { + Err(Error::custom(format!("'{RESIDENT_KEY}' is not '{REQUIRED}', but '{REQUIRE_RESIDENT_KEY}' is true"))) + } else { + Ok(Some(opt)) + } + } + } + }))) + })?; + } + Field::UserVerification => { + if uv.is_some() { + return Err(Error::duplicate_field(USER_VERIFICATION)); + } + uv = map.next_value::<Option<_>>().map(Some)?; + } + } + } + Ok(AuthenticatorSelectionCriteria { + authenticator_attachment: attach.flatten().unwrap_or_default(), + resident_key: res_key.flatten().unwrap_or_else(|| { + if res_req.flatten().is_some_and(convert::identity) { + ResidentKeyRequirement::Required + } else { + ResidentKeyRequirement::Discouraged + } + }), + user_verification: uv + .flatten() + .unwrap_or(UserVerificationRequirement::Preferred), + }) + } + } + /// Fields for `AuthenticatorSelectionCriteria`. + const FIELDS: &[&str; 4] = &[ + AUTHENTICATOR_ATTACHMENT, + RESIDENT_KEY, + REQUIRE_RESIDENT_KEY, + USER_VERIFICATION, + ]; + deserializer.deserialize_struct( + "AuthenticatorSelectionCriteria", + FIELDS, + AuthenticatorSelectionCriteriaVisitor, + ) + } +} +/// Helper for [`ClientCredentialCreationOptions::deserialize`]. +struct Attestation; +impl<'de> Deserialize<'de> for Attestation { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `Attestation`. + struct AttestationVisitor; + impl Visitor<'_> for AttestationVisitor { + type Value = Attestation; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(NONE) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + if v == NONE { + Ok(Attestation) + } else { + Err(E::invalid_value(Unexpected::Str(v), &NONE)) + } + } + } + deserializer.deserialize_str(AttestationVisitor) + } +} +/// Helper for [`ClientCredentialCreationOptions::deserialize`]. +struct AttestationFormats; +impl<'de> Deserialize<'de> for AttestationFormats { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `AttestationFormats`. + struct AttestationFormatsVisitor; + impl<'d> Visitor<'d> for AttestationFormatsVisitor { + type Value = AttestationFormats; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("AttestationFormats") + } + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'d>, + { + seq.next_element::<Attestation>().and_then(|opt| { + opt.map_or(Ok(AttestationFormats), |_f| { + seq.next_element::<Attestation>().and_then(|opt2| { + opt2.map_or(Ok(AttestationFormats), |_val| Err(Error::custom("attestationFormats must be an empty sequence or contain exactly one string whose value is 'none'"))) + }) + }) + }) + } + } + deserializer.deserialize_seq(AttestationFormatsVisitor) + } +} +/// Similar to [`Extension`] except [`PrfInputOwned`] is used. +/// +/// This is primarily useful to assist [`ClientCredentialCreationOptions::deserialize`]. +#[derive(Debug, Default)] +pub struct ExtensionOwned { + /// See [`Extension::cred_props`]. + pub cred_props: Option<ExtensionReq>, + /// See [`Extension::cred_protect`]. + pub cred_protect: CredProtect, + /// See [`Extension::min_pin_length`]. + pub min_pin_length: Option<(FourToSixtyThree, ExtensionInfo)>, + /// See [`Extension::prf`]. + pub prf: Option<PrfInputOwned>, +} +impl<'a: 'prf_first + 'prf_second, 'prf_first, 'prf_second> From<&'a ExtensionOwned> + for Extension<'prf_first, 'prf_second> +{ + #[inline] + fn from(value: &'a ExtensionOwned) -> Self { + Self { + cred_props: value.cred_props, + cred_protect: value.cred_protect, + min_pin_length: value.min_pin_length, + prf: value.prf.as_ref().map(|input| { + ( + PrfInput { + first: input.first.as_slice(), + second: input.second.as_deref(), + }, + ExtensionInfo::AllowEnforceValue, + ) + }), + } + } +} +impl<'de> Deserialize<'de> for ExtensionOwned { + /// Deserializes a `struct` according to the following pseudo-schema: + /// + /// ```json + /// { + /// "credProps": null | false | true, + /// "credentialProtectionPolicy": null | "userVerificationOptional" | "userVerificationOptionalWithCredentialIDList" | "userVerificationRequired", + /// "enforceCredentialProtectionPolicy": null | false | true, + /// "minPinLength": null | false | true, + /// "prf": null | PRFJSON + /// } + /// // PRFJSON: + /// { + /// "eval": PRFInputs + /// } + /// // PRFInputs: + /// { + /// "first": <base64url-encoded string>, + /// "second": null | <base64url-encoded string> + /// } + /// ``` + /// + /// where the only required fields are `"eval"` and `"first"`. Additionally `"credentialProtectionPolicy"` + /// must exist if `"enforceCredentialProtectionPolicy"` exists, and it must not be `null` if the latter + /// is not `null`. If the former is defined and not `null` but the latter is not defined or is `null`, then + /// `false` will be used for the latter. Unknown or duplicate fields lead to an error. + /// + /// All extensions are not required to have a response sent back; but _if_ a response is sent back, its value + /// will be enforced. In the case of `"minPinLength"`, [`FourToSixtyThree::Four`] will be the minimum + /// length enforced (i.e., any valid response is guaranteed to satisfy since it will have length at least + /// as large). + /// + /// Unknown or duplicate fields lead to an error. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::{ExtensionInfo, ExtensionReq, register::{CredProtect, FourToSixtyThree, ser::ExtensionOwned}}; + /// let ext = serde_json::from_str::<ExtensionOwned>( + /// r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{"eval":{"first":"","second":null}}}"#, + /// )?; + /// assert!( + /// ext.cred_props + /// .map_or(false, |req| matches!(req, ExtensionReq::Allow)) + /// ); + /// assert!( + /// matches!(ext.cred_protect, CredProtect::UserVerificationRequired(enforce, info) if !enforce && matches!(info, ExtensionInfo::AllowEnforceValue)) + /// ); + /// assert!(ext.min_pin_length.map_or(false, |pin| pin.0 == FourToSixtyThree::Four + /// && matches!(pin.1, ExtensionInfo::AllowEnforceValue))); + /// assert!(ext.prf.map_or(false, |prf| prf.first.is_empty() + /// && prf.second.is_none() + /// && matches!(prf.ext_req, ExtensionReq::Allow))); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[expect(clippy::too_many_lines, reason = "want to keep logic internal")] + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `ExtensionOwned`. + struct ExtensionOwnedVisitor; + impl<'d> Visitor<'d> for ExtensionOwnedVisitor { + type Value = ExtensionOwned; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("ExtensionOwned") + } + #[expect(clippy::too_many_lines, reason = "want to keep logic internal")] + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `ExtensionOwned`. + enum Field { + /// `credProps`. + CredProps, + /// `credentialProtectionPolicy`. + CredentialProtectionPolicy, + /// `enforceCredentialProtectionPolicy`. + EnforceCredentialProtectionPolicy, + /// `minPinLength`. + MinPinLength, + /// `prf` + Prf, + } + 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, + "'{CRED_PROPS}', '{CREDENTIAL_PROTECTION_POLICY}', '{ENFORCE_CREDENTIAL_PROTECTION_POLICY}', '{MIN_PIN_LENGTH}', or '{PRF}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + CRED_PROPS => Ok(Field::CredProps), + CREDENTIAL_PROTECTION_POLICY => { + Ok(Field::CredentialProtectionPolicy) + } + ENFORCE_CREDENTIAL_PROTECTION_POLICY => { + Ok(Field::EnforceCredentialProtectionPolicy) + } + MIN_PIN_LENGTH => Ok(Field::MinPinLength), + PRF => Ok(Field::Prf), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + /// Credential protection policy values. + #[expect(clippy::enum_variant_names, reason = "consistent with ctap names")] + enum Policy { + /// `userVerificationOptional`. + UserVerificationOptional, + /// `userVerificationOptionalWithCredentialIdList`. + UserVerificationOptionalWithCredentialIdLisit, + /// `userVerificationRequired`. + UserVerificationRequired, + } + impl<'e> Deserialize<'e> for Policy { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + /// `Visitor` for `Policy`. + struct PolicyVisitor; + impl Visitor<'_> for PolicyVisitor { + type Value = Policy; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "'{USER_VERIFICATION_OPTIONAL}', '{USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST}', or '{USER_VERIFICATION_REQUIRED}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + USER_VERIFICATION_OPTIONAL => Ok(Policy::UserVerificationOptional), + USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST => Ok(Policy::UserVerificationOptionalWithCredentialIdLisit), + USER_VERIFICATION_REQUIRED => Ok(Policy::UserVerificationRequired), + _ => Err(E::invalid_value(Unexpected::Str(v), &format!("'{USER_VERIFICATION_OPTIONAL}', '{USER_VERIFICATION_OPTIONAL_WITH_CREDENTIAL_ID_LIST}', or '{USER_VERIFICATION_REQUIRED}'").as_str())), + } + } + } + deserializer.deserialize_str(PolicyVisitor) + } + } + let mut props: Option<Option<bool>> = None; + let mut policy = None; + let mut enforce = None; + let mut pin: Option<Option<bool>> = None; + let mut prf_inputs = None; + while let Some(key) = map.next_key()? { + match key { + Field::CredProps => { + if props.is_some() { + return Err(Error::duplicate_field(CRED_PROPS)); + } + props = map.next_value().map(Some)?; + } + Field::CredentialProtectionPolicy => { + if policy.is_some() { + return Err(Error::duplicate_field(CREDENTIAL_PROTECTION_POLICY)); + } + policy = map.next_value::<Option<Policy>>().map(Some)?; + } + Field::EnforceCredentialProtectionPolicy => { + if enforce.is_some() { + return Err(Error::duplicate_field( + ENFORCE_CREDENTIAL_PROTECTION_POLICY, + )); + } + enforce = map.next_value::<Option<_>>().map(Some)?; + } + Field::MinPinLength => { + if pin.is_some() { + return Err(Error::duplicate_field(MIN_PIN_LENGTH)); + } + pin = map.next_value().map(Some)?; + } + Field::Prf => { + if prf_inputs.is_some() { + return Err(Error::duplicate_field(PRF)); + } + prf_inputs = map + .next_value::<Option<PrfHelper>>() + .map(|opt| Some(opt.map(|p| p.0)))?; + } + } + } + policy.map_or_else( + || { + if enforce.is_some() { + Err(Error::custom(format!("'{ENFORCE_CREDENTIAL_PROTECTION_POLICY}' must not exist when '{CREDENTIAL_PROTECTION_POLICY}' does not exist"))) + } else { + Ok(CredProtect::None) + } + }, + |opt_policy| opt_policy.map_or_else( + || { + if enforce.is_some_and(|opt| opt.is_some()) { + Err(Error::custom(format!("'{ENFORCE_CREDENTIAL_PROTECTION_POLICY}' must be null or not exist when '{CREDENTIAL_PROTECTION_POLICY}' is null"))) + } else { + Ok(CredProtect::None) + } + }, + |cred_policy| { + match cred_policy { + Policy::UserVerificationOptional => Ok(CredProtect::UserVerificationOptional(enforce.flatten().unwrap_or_default(), ExtensionInfo::AllowEnforceValue)), + Policy::UserVerificationOptionalWithCredentialIdLisit => Ok(CredProtect::UserVerificationOptionalWithCredentialIdList(enforce.flatten().unwrap_or_default(), ExtensionInfo::AllowEnforceValue)), + Policy::UserVerificationRequired => Ok(CredProtect::UserVerificationRequired(enforce.flatten().unwrap_or_default(), ExtensionInfo::AllowEnforceValue)), + } + } + ), + ).map(|cred_protect| { + ExtensionOwned { cred_props: props.flatten().and_then(|p| p.then_some(ExtensionReq::Allow)), cred_protect, min_pin_length: pin.flatten().and_then(|m| m.then_some((FourToSixtyThree::Four, ExtensionInfo::AllowEnforceValue))), prf: prf_inputs.flatten(), } + }) + } + } + /// Fields for `ExtensionOwned`. + const FIELDS: &[&str; 5] = &[ + CRED_PROPS, + CREDENTIAL_PROTECTION_POLICY, + ENFORCE_CREDENTIAL_PROTECTION_POLICY, + MIN_PIN_LENGTH, + PRF, + ]; + deserializer.deserialize_struct("ExtensionOwned", FIELDS, ExtensionOwnedVisitor) + } +} +/// Similar to [`PublicKeyCredentialCreationOptions`] except the fields are based on owned data. +/// +/// This is primarily useful to assist [`ClientCredentialCreationOptions::deserialize`]. +#[derive(Debug)] +pub struct PublicKeyCredentialCreationOptionsOwned< + 'user_name, + 'user_display_name, + const USER_LEN: usize, +> { + /// See [`PublicKeyCredentialCreationOptions::rp_id`]. + pub rp_id: RpId, + /// See [`PublicKeyCredentialCreationOptions::user`]. + pub user: PublicKeyCredentialUserEntityOwned<'user_name, 'user_display_name, USER_LEN>, + /// See [`PublicKeyCredentialCreationOptions::pub_key_cred_params`]. + pub pub_key_cred_params: CoseAlgorithmIdentifiers, + /// See [`PublicKeyCredentialCreationOptions::timeout`]. + pub timeout: NonZeroU32, + /// See [`PublicKeyCredentialCreationOptions::authenticator_selection`]. + pub authenticator_selection: AuthenticatorSelectionCriteria, + /// See [`PublicKeyCredentialCreationOptions::extensions`]. + pub extensions: ExtensionOwned, +} +impl<const USER_LEN: usize> PublicKeyCredentialCreationOptionsOwned<'_, '_, USER_LEN> { + /// Creates a `PublicKeyCredentialCreationOptions` based on the contained data and randomly-generated + /// [`Challenge`]. + #[inline] + #[must_use] + pub fn into_options( + &self, + exclude_credentials: Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, + ) -> PublicKeyCredentialCreationOptions<'_, '_, '_, '_, '_, '_, USER_LEN> { + PublicKeyCredentialCreationOptions { + rp_id: &self.rp_id, + user: (&self.user).into(), + challenge: Challenge::new(), + pub_key_cred_params: self.pub_key_cred_params, + timeout: self.timeout, + exclude_credentials, + authenticator_selection: self.authenticator_selection, + extensions: (&self.extensions).into(), + } + } +} +impl<'user_name, 'user_display_name, const USER_LEN: usize> Default + for PublicKeyCredentialCreationOptionsOwned<'user_name, 'user_display_name, USER_LEN> +where + PublicKeyCredentialUserEntityOwned<'user_name, 'user_display_name, USER_LEN>: Default, +{ + #[inline] + fn default() -> Self { + Self { + rp_id: DEFAULT_RP_ID, + user: PublicKeyCredentialUserEntityOwned::default(), + pub_key_cred_params: CoseAlgorithmIdentifiers::default(), + timeout: FIVE_MINUTES, + authenticator_selection: AuthenticatorSelectionCriteria { + authenticator_attachment: AuthenticatorAttachmentReq::default(), + resident_key: ResidentKeyRequirement::Discouraged, + user_verification: UserVerificationRequirement::Preferred, + }, + extensions: ExtensionOwned::default(), + } + } +} +impl<'de: 'user_name + 'user_display_name, 'user_name, 'user_display_name, const USER_LEN: usize> + Deserialize<'de> + for PublicKeyCredentialCreationOptionsOwned<'user_name, 'user_display_name, USER_LEN> +where + UserHandle<USER_LEN>: Default, + PublicKeyCredentialUserEntityOwned<'user_name, 'user_display_name, USER_LEN>: Default, +{ + /// Deserializes a `struct` based on + /// [`PublicKeyCredentialCreationOptionsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptionsjson). + /// + /// Note that none of the fields are required, and all are allowed to be `null`. + /// [`authenticatorAttachment`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-authenticatorattachment) + /// must be consistent with + /// [`hints`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-hints) + /// (e.g., if [`"platform"`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorattachment-platform) is + /// requested, then `hints` must either not exist, be `null`, be empty, or be `["client-device"]`). + /// + /// If [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-challenge) + /// exists, it must be `null`. If + /// [`excludeCredentials`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-excludecredentials) + /// exists, it must be `null` or empty. If + /// [`attestation`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-attestation) + /// exists, it must be `null`or `"none"`. If + /// [`attestationFormats`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-attestationformats) + /// exists, it must be `null`, empty, or `["none"]`. + /// + /// If [`timeout`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-timeout) exists, + /// it must be `null` or positive. + /// + /// In the event there is no RP ID defined, the value `"example.invalid"` will be used. + /// + /// For any field that does not exist or is `null`, the corresponding [`Default`] `impl` will be used. For + /// [`AuthenticatorSelectionCriteria`], `AuthenticatorAttachmentReq::None(Hint::None)`, + /// [`ResidentKeyRequirement::Discouraged`], and [`UserVerificationRequirement::Preferred`] will be used. + /// For `timeout`, [`FIVE_MINUTES`] will be used. + /// + /// Unknown or duplicate fields lead to an error. + #[expect(clippy::too_many_lines, reason = "want to keep logic internal")] + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `PublicKeyCredentialCreationOptionsOwned`. + struct PublicKeyCredentialCreationOptionsOwnedVisitor<'a, 'b, const LEN: usize>( + PhantomData<fn() -> (&'a (), &'b ())>, + ); + impl<'d: 'a + 'b, 'a, 'b, const LEN: usize> Visitor<'d> + for PublicKeyCredentialCreationOptionsOwnedVisitor<'a, 'b, LEN> + where + UserHandle<LEN>: Default, + PublicKeyCredentialUserEntityOwned<'a, 'b, LEN>: Default, + { + type Value = PublicKeyCredentialCreationOptionsOwned<'a, 'b, LEN>; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("PublicKeyCredentialCreationOptionsOwned") + } + #[expect(clippy::too_many_lines, reason = "want to keep logic internal")] + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `PublicKeyCredentialCreationOptionsOwned`. + enum Field { + /// `rp`. + Rp, + /// `user`. + User, + /// `challenge`. + Challenge, + /// `pubKeyCredParams`. + PubKeyCredParams, + /// `timeout`. + Timeout, + /// `excludeCredentials`. + ExcludeCredentials, + /// `authenticatorSelection`. + AuthenticatorSelection, + /// `hints`. + Hints, + /// `extensions`. + Extensions, + /// `attestation`. + Attestation, + /// `attestationFormats`. + AttestationFormats, + } + 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, + "'{RP}', '{USER}', '{CHALLENGE}', '{PUB_KEY_CRED_PARAMS}', '{TIMEOUT}', '{EXCLUDE_CREDENTIALS}', '{AUTHENTICATOR_SELECTION}', '{HINTS}', '{EXTENSIONS}', '{ATTESTATION}', or '{ATTESTATION_FORMATS}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + RP => Ok(Field::Rp), + USER => Ok(Field::User), + CHALLENGE => Ok(Field::Challenge), + PUB_KEY_CRED_PARAMS => Ok(Field::PubKeyCredParams), + TIMEOUT => Ok(Field::Timeout), + EXCLUDE_CREDENTIALS => Ok(Field::ExcludeCredentials), + AUTHENTICATOR_SELECTION => Ok(Field::AuthenticatorSelection), + HINTS => Ok(Field::Hints), + EXTENSIONS => Ok(Field::Extensions), + ATTESTATION => Ok(Field::Attestation), + ATTESTATION_FORMATS => Ok(Field::AttestationFormats), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut rp = None; + let mut user_info = None; + let mut chall = None; + let mut params = None; + let mut time = None; + let mut exclude = None; + let mut auth = None; + let mut hint: Option<Hint> = None; + let mut ext = None; + let mut attest = None; + let mut formats = None; + while let Some(key) = map.next_key()? { + match key { + Field::Rp => { + if rp.is_some() { + return Err(Error::duplicate_field(RP)); + } + rp = map + .next_value::<Option<PublicKeyCredentialRpEntityHelper>>() + .map(|opt| Some(opt.map(|val| val.0)))?; + } + Field::User => { + if user_info.is_some() { + return Err(Error::duplicate_field(USER)); + } + user_info = map.next_value::<Option<_>>().map(Some)?; + } + Field::Challenge => { + if chall.is_some() { + return Err(Error::duplicate_field(CHALLENGE)); + } + chall = map.next_value::<Null>().map(Some)?; + } + Field::PubKeyCredParams => { + if params.is_some() { + return Err(Error::duplicate_field(PUB_KEY_CRED_PARAMS)); + } + params = map.next_value::<Option<_>>().map(Some)?; + } + Field::Timeout => { + if time.is_some() { + return Err(Error::duplicate_field(TIMEOUT)); + } + time = map.next_value::<Option<_>>().map(Some)?; + } + Field::ExcludeCredentials => { + if exclude.is_some() { + return Err(Error::duplicate_field(EXCLUDE_CREDENTIALS)); + } + exclude = map.next_value::<Option<[(); 0]>>().map(Some)?; + } + Field::AuthenticatorSelection => { + if auth.is_some() { + return Err(Error::duplicate_field(AUTHENTICATOR_SELECTION)); + } + auth = map.next_value::<Option<AuthenticatorSelectionCriteria>>().and_then(|opt| { + opt.map_or(Ok(Some(AuthenticatorSelectionCriteria { authenticator_attachment: AuthenticatorAttachmentReq::default(), resident_key: ResidentKeyRequirement::Discouraged, user_verification: UserVerificationRequirement::Preferred, })), |mut crit| { + let h = hint.unwrap_or_default(); + match crit.authenticator_attachment { + AuthenticatorAttachmentReq::None(ref mut hi) => { + *hi = h; + Ok(Some(crit)) + } + AuthenticatorAttachmentReq::Platform(ref mut hi) => { + match h { + Hint::None => Ok(Some(crit)), + Hint::ClientDevice => { + *hi = PlatformHint::ClientDevice; + Ok(Some(crit)) + } + Hint::SecurityKey | Hint::Hybrid | Hint::SecurityKeyClientDevice | Hint::ClientDeviceSecurityKey | Hint::SecurityKeyHybrid | Hint::HybridSecurityKey | Hint::ClientDeviceHybrid | Hint::HybridClientDevice | Hint::SecurityKeyClientDeviceHybrid | Hint::SecurityKeyHybridClientDevice | Hint::ClientDeviceSecurityKeyHybrid | Hint::ClientDeviceHybridSecurityKey | Hint::HybridSecurityKeyClientDevice | Hint::HybridClientDeviceSecurityKey => Err(Error::custom("'platform' authenticator attachment modality must coincide with no hints or 'client-device' hint")), + } + } + AuthenticatorAttachmentReq::CrossPlatform(ref mut hi) => { + match h { + Hint::None => Ok(Some(crit)), + Hint::SecurityKey => { + *hi = CrossPlatformHint::SecurityKey; + Ok(Some(crit)) + } + Hint::Hybrid => { + *hi = CrossPlatformHint::Hybrid; + Ok(Some(crit)) + } + Hint::SecurityKeyHybrid => { + *hi = CrossPlatformHint::SecurityKeyHybrid; + Ok(Some(crit)) + } + Hint::HybridSecurityKey => { + *hi = CrossPlatformHint::HybridSecurityKey; + Ok(Some(crit)) + } + Hint::ClientDevice | Hint::SecurityKeyClientDevice | Hint::ClientDeviceSecurityKey | Hint::ClientDeviceHybrid | Hint::HybridClientDevice | Hint::SecurityKeyClientDeviceHybrid | Hint::SecurityKeyHybridClientDevice | Hint::ClientDeviceSecurityKeyHybrid | Hint::ClientDeviceHybridSecurityKey | Hint::HybridSecurityKeyClientDevice | Hint::HybridClientDeviceSecurityKey => Err(Error::custom("'cross-platform' authenticator attachment modality must coincide with no hints or hints that lack 'client-device'")), + } + } + } + }) + })?; + } + Field::Hints => { + if hint.is_some() { + return Err(Error::duplicate_field(HINTS)); + } + hint = map.next_value::<Option<Hint>>().and_then(|opt| { + opt.map_or(Ok(Some(Hint::None)), |h| { + auth.as_mut().map_or(Ok(Some(h)), |crit| { + match crit.authenticator_attachment { + AuthenticatorAttachmentReq::None(ref mut hi) => { + *hi = h; + Ok(Some(h)) + } + AuthenticatorAttachmentReq::Platform(ref mut hi) => { + match h{ + Hint::None => Ok(Some(h)), + Hint::ClientDevice => { + *hi = PlatformHint::ClientDevice; + Ok(Some(h)) + } + Hint::SecurityKey | Hint::Hybrid | Hint::SecurityKeyClientDevice | Hint::ClientDeviceSecurityKey | Hint::SecurityKeyHybrid | Hint::HybridSecurityKey | Hint::ClientDeviceHybrid | Hint::HybridClientDevice | Hint::SecurityKeyClientDeviceHybrid | Hint::SecurityKeyHybridClientDevice | Hint::ClientDeviceSecurityKeyHybrid | Hint::ClientDeviceHybridSecurityKey | Hint::HybridSecurityKeyClientDevice | Hint::HybridClientDeviceSecurityKey => Err(Error::custom("'platform' authenticator attachment modality must coincide with no hints or 'client-device' hint")), + } + } + AuthenticatorAttachmentReq::CrossPlatform(ref mut hi) => { + match h { + Hint::None => Ok(Some(h)), + Hint::SecurityKey => { + *hi = CrossPlatformHint::SecurityKey; + Ok(Some(h)) + } + Hint::Hybrid => { + *hi = CrossPlatformHint::Hybrid; + Ok(Some(h)) + } + Hint::SecurityKeyHybrid => { + *hi = CrossPlatformHint::SecurityKeyHybrid; + Ok(Some(h)) + } + Hint::HybridSecurityKey => { + *hi = CrossPlatformHint::HybridSecurityKey; + Ok(Some(h)) + } + Hint::ClientDevice | Hint::SecurityKeyClientDevice | Hint::ClientDeviceSecurityKey | Hint::ClientDeviceHybrid | Hint::HybridClientDevice | Hint::SecurityKeyClientDeviceHybrid | Hint::SecurityKeyHybridClientDevice | Hint::ClientDeviceSecurityKeyHybrid | Hint::ClientDeviceHybridSecurityKey | Hint::HybridSecurityKeyClientDevice | Hint::HybridClientDeviceSecurityKey => Err(Error::custom("'cross-platform' authenticator attachment modality must coincide with no hints or hints that lack 'client-device'")), + } + } + } + }) + }) + })?; + } + Field::Extensions => { + if ext.is_some() { + return Err(Error::duplicate_field(EXTENSIONS)); + } + ext = map.next_value::<Option<_>>().map(Some)?; + } + Field::Attestation => { + if attest.is_some() { + return Err(Error::duplicate_field(ATTESTATION)); + } + attest = map.next_value::<Option<Attestation>>().map(Some)?; + } + Field::AttestationFormats => { + if formats.is_some() { + return Err(Error::duplicate_field(ATTESTATION_FORMATS)); + } + formats = map.next_value::<Option<AttestationFormats>>().map(Some)?; + } + } + } + Ok(PublicKeyCredentialCreationOptionsOwned { + rp_id: rp.flatten().unwrap_or(DEFAULT_RP_ID), + user: user_info.flatten().unwrap_or_default(), + pub_key_cred_params: params.flatten().unwrap_or_default(), + timeout: time.flatten().unwrap_or(FIVE_MINUTES), + authenticator_selection: auth.unwrap_or_else(|| { + AuthenticatorSelectionCriteria { + authenticator_attachment: AuthenticatorAttachmentReq::default(), + resident_key: ResidentKeyRequirement::Discouraged, + user_verification: UserVerificationRequirement::Preferred, + } + }), + extensions: ext.flatten().unwrap_or_default(), + }) + } + } + /// Fields for `PublicKeyCredentialCreationOptionsOwned`. + const FIELDS: &[&str; 11] = &[ + RP, + USER, + CHALLENGE, + PUB_KEY_CRED_PARAMS, + TIMEOUT, + EXCLUDE_CREDENTIALS, + AUTHENTICATOR_SELECTION, + HINTS, + EXTENSIONS, + ATTESTATION, + ATTESTATION_FORMATS, + ]; + deserializer.deserialize_struct( + "PublicKeyCredentialCreationOptionsOwned", + FIELDS, + PublicKeyCredentialCreationOptionsOwnedVisitor(PhantomData), + ) + } +} +/// Deserializes client-supplied data to assist in the creation of [`CredentialCreationOptions`]. +/// +/// It's common to tailor a registration ceremony based on a user's environment. The options that should be +/// used are then sent to the server. For example, [`CredentialMediationRequirement::Conditional`] ceremonies +/// typically work best for [`AuthenticatorAttachment::Platform`] authenticators; a subset of which cannot +/// rely on [`UserVerificationRequirement::Required`]. Unfortunately one may not want to use +/// [`UserVerificationRequirement::Preferred`] unconditionally either since security keys may benefit from +/// [`CredProtect::UserVerificationRequired`] which can typically only be used when +/// [`UserVerificationRequirement::Required`] is requested since many user agents error otherwise. +/// +/// To facilitate this, [`Self::deserialize`] can be used to deserialize the data sent from the client. Upon +/// successful deserialization, [`Self::into_options`] can then be used to construct the appropriate +/// [`CredentialCreationOptions`]. +/// +/// Note one may want to change some of the [`Extension`] data since [`ExtensionInfo::AllowEnforceValue`] and +/// [`ExtensionReq::Allow`] are unconditionally used. Read [`ExtensionOwned::deserialize`] for more information. +/// +/// Additionally, one may want to change the value of [`PublicKeyCredentialCreationOptions::rp_id`] since +/// `"example.invalid"` is used in the event the RP ID was not supplied. +#[derive(Debug)] +pub struct ClientCredentialCreationOptions<'user_name, 'user_display_name, const USER_LEN: usize> { + /// See [`CredentialCreationOptions::mediation`]. + pub mediation: CredentialMediationRequirement, + /// See [`CredentialCreationOptions::public_key`]. + pub public_key: + PublicKeyCredentialCreationOptionsOwned<'user_name, 'user_display_name, USER_LEN>, +} +impl<const USER_LEN: usize> ClientCredentialCreationOptions<'_, '_, USER_LEN> { + /// Creates a `CredentialCreationOptions` based on the contained data where + /// [`CredentialCreationOptions::public_key`] is constructed via + /// [`PublicKeyCredentialCreationOptionsOwned::into_options`]. + #[inline] + #[must_use] + pub fn into_options( + &self, + exclude_credentials: Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, + ) -> CredentialCreationOptions<'_, '_, '_, '_, '_, '_, USER_LEN> { + CredentialCreationOptions { + mediation: self.mediation, + public_key: self.public_key.into_options(exclude_credentials), + } + } +} +impl<'user_name, 'user_display_name, const USER_LEN: usize> Default + for ClientCredentialCreationOptions<'user_name, 'user_display_name, USER_LEN> +where + PublicKeyCredentialCreationOptionsOwned<'user_name, 'user_display_name, USER_LEN>: Default, +{ + #[inline] + fn default() -> Self { + Self { + mediation: CredentialMediationRequirement::default(), + public_key: PublicKeyCredentialCreationOptionsOwned::default(), + } + } +} +impl<'de: 'user_name + 'user_display_name, 'user_name, 'user_display_name, const USER_LEN: usize> + Deserialize<'de> for ClientCredentialCreationOptions<'user_name, 'user_display_name, USER_LEN> +where + UserHandle<USER_LEN>: Default, + PublicKeyCredentialCreationOptionsOwned<'user_name, 'user_display_name, USER_LEN>: Default, +{ + /// Deserializes a `struct` according to the following pseudo-schema: + /// + /// ```json + /// { + /// "mediation": null | "required" | "conditional", + /// "publicKey": null | <PublicKeyCredentialCreationOptionsOwned> + /// } + /// ``` + /// + /// where none of the fields are required and `"publicKey"` is deserialized according to + /// [`PublicKeyCredentialCreationOptionsOwned::deserialize`]. If any field is missing or is `null`, then + /// the corresponding [`Default`] `impl` will be used. + /// + /// Unknown or duplicate fields lead to an error. + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `ClientCredentialCreationOptions`. + struct ClientCredentialCreationOptionsVisitor<'a, 'b, const LEN: usize>( + PhantomData<fn() -> (&'a (), &'b ())>, + ); + impl<'d: 'a + 'b, 'a, 'b, const LEN: usize> Visitor<'d> + for ClientCredentialCreationOptionsVisitor<'a, 'b, LEN> + where + UserHandle<LEN>: Default, + PublicKeyCredentialCreationOptionsOwned<'a, 'b, LEN>: Default, + { + type Value = ClientCredentialCreationOptions<'a, 'b, LEN>; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("ClientCredentialCreationOptions") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field in `ClientCredentialCreationOptions`. + enum Field { + /// `mediation`. + Mediation, + /// `publicKey` + PublicKey, + } + 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, "'{MEDIATION}' or '{PUBLIC_KEY_NO_HYPEN}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + MEDIATION => Ok(Field::Mediation), + PUBLIC_KEY_NO_HYPEN => Ok(Field::PublicKey), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut med = None; + let mut key = None; + while let Some(k) = map.next_key()? { + match k { + Field::Mediation => { + if med.is_some() { + return Err(Error::duplicate_field(MEDIATION)); + } + med = map.next_value::<Option<_>>().map(Some)?; + } + Field::PublicKey => { + if key.is_some() { + return Err(Error::duplicate_field(PUBLIC_KEY_NO_HYPEN)); + } + key = map.next_value::<Option<_>>().map(Some)?; + } + } + } + Ok(ClientCredentialCreationOptions { + mediation: med.flatten().unwrap_or_default(), + public_key: key.flatten().unwrap_or_default(), + }) + } + } + /// Fields for `ClientCredentialCreationOptions`. + const FIELDS: &[&str; 2] = &[MEDIATION, PUBLIC_KEY_NO_HYPEN]; + deserializer.deserialize_struct( + "ClientCredentialCreationOptions", + FIELDS, + ClientCredentialCreationOptionsVisitor(PhantomData), + ) + } +} +#[cfg(test)] +mod test { + use super::{ + AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, + ClientCredentialCreationOptions, CoseAlgorithmIdentifier, CoseAlgorithmIdentifiers, + CredProtect, CredentialMediationRequirement, CrossPlatformHint, DEFAULT_RP_ID, + ExtensionInfo, ExtensionOwned, ExtensionReq, FIVE_MINUTES, FourToSixtyThree, Hint, + NonZeroU32, PlatformHint, PublicKeyCredentialCreationOptionsOwned, + PublicKeyCredentialUserEntityOwned, ResidentKeyRequirement, UserVerificationRequirement, + }; + use serde_json::Error; + #[test] + fn client_options() -> Result<(), Error> { + let mut err = + serde_json::from_str::<ClientCredentialCreationOptions<'_, '_, 16>>(r#"{"bob":true}"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..56], + *"unknown field `bob`, expected `mediation` or `publicKey`" + ); + err = serde_json::from_str::<ClientCredentialCreationOptions<'_, '_, 1>>( + r#"{"mediation":"required","mediation":"required"}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..27], *"duplicate field `mediation`"); + let mut options = + serde_json::from_str::<ClientCredentialCreationOptions<'_, '_, 1>>(r#"{}"#)?; + assert!(matches!( + options.mediation, + CredentialMediationRequirement::Required + )); + assert_eq!(options.public_key.rp_id, DEFAULT_RP_ID); + assert_eq!(options.public_key.user.name.as_ref(), "blank"); + assert!(options.public_key.user.display_name.is_none()); + assert_eq!( + options.public_key.pub_key_cred_params.0, + CoseAlgorithmIdentifiers::ALL.0 + ); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!( + matches!(options.public_key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + options.public_key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + options.public_key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(options.public_key.extensions.cred_props.is_none()); + assert!(matches!( + options.public_key.extensions.cred_protect, + CredProtect::None + )); + assert!(options.public_key.extensions.min_pin_length.is_none()); + assert!(options.public_key.extensions.prf.is_none()); + options = serde_json::from_str::<ClientCredentialCreationOptions<'_, '_, 1>>( + r#"{"mediation":null,"publicKey":null}"#, + )?; + assert!(matches!( + options.mediation, + CredentialMediationRequirement::Required + )); + assert_eq!(options.public_key.rp_id, DEFAULT_RP_ID); + assert_eq!(options.public_key.user.name.as_ref(), "blank"); + assert!(options.public_key.user.display_name.is_none()); + assert_eq!( + options.public_key.pub_key_cred_params.0, + CoseAlgorithmIdentifiers::ALL.0 + ); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!( + matches!(options.public_key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + options.public_key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + options.public_key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(options.public_key.extensions.cred_props.is_none()); + assert!(matches!( + options.public_key.extensions.cred_protect, + CredProtect::None + )); + assert!(options.public_key.extensions.min_pin_length.is_none()); + assert!(options.public_key.extensions.prf.is_none()); + options = serde_json::from_str::<ClientCredentialCreationOptions<'_, '_, 1>>( + r#"{"publicKey":{}}"#, + )?; + assert_eq!(options.public_key.rp_id, DEFAULT_RP_ID); + assert_eq!(options.public_key.user.name.as_ref(), "blank"); + assert!(options.public_key.user.display_name.is_none()); + assert_eq!( + options.public_key.pub_key_cred_params.0, + CoseAlgorithmIdentifiers::ALL.0 + ); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!( + matches!(options.public_key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + options.public_key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + options.public_key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(options.public_key.extensions.cred_props.is_none()); + assert!(matches!( + options.public_key.extensions.cred_protect, + CredProtect::None + )); + assert!(options.public_key.extensions.min_pin_length.is_none()); + assert!(options.public_key.extensions.prf.is_none()); + options = serde_json::from_str::<ClientCredentialCreationOptions<'_, '_, 1>>( + r#"{"mediation":"conditional","publicKey":{"rp":{"name":"Example.com","id":"example.com"},"user":{"name":"bob","displayName":"Bob","id":"AA"},"timeout":300000,"excludeCredentials":[],"attestation":"none","attestationFormats":["none"],"authenticatorSelection":{"authenticatorAttachment":"cross-platform","residentKey":"required","requireResidentKey":true,"userVerification":"required"},"extensions":{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{"eval":{"first":"","second":""}}},"pubKeyCredParams":[{"type":"public-key","alg":-8}],"hints":["security-key"],"challenge":null}}"#, + )?; + assert!(matches!( + options.mediation, + CredentialMediationRequirement::Conditional + )); + assert_eq!(options.public_key.rp_id.as_ref(), "example.com"); + assert_eq!(options.public_key.user.name.as_ref(), "bob"); + assert!( + options + .public_key + .user + .display_name + .map_or(false, |name| name.as_ref() == "Bob") + ); + assert_eq!( + options.public_key.pub_key_cred_params.0, + CoseAlgorithmIdentifiers::ALL + .remove(CoseAlgorithmIdentifier::Es256) + .remove(CoseAlgorithmIdentifier::Es384) + .remove(CoseAlgorithmIdentifier::Rs256) + .0 + ); + assert_eq!(options.public_key.timeout, FIVE_MINUTES); + assert!( + matches!(options.public_key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::CrossPlatform(hint) if matches!(hint, CrossPlatformHint::SecurityKey)) + ); + assert!(matches!( + options.public_key.authenticator_selection.resident_key, + ResidentKeyRequirement::Required + )); + assert!(matches!( + options.public_key.authenticator_selection.user_verification, + UserVerificationRequirement::Required + )); + assert!( + options + .public_key + .extensions + .cred_props + .map_or(false, |req| matches!(req, ExtensionReq::Allow)) + ); + assert!( + matches!(options.public_key.extensions.cred_protect, CredProtect::UserVerificationRequired(enforce, info) if !enforce && matches!(info, ExtensionInfo::AllowEnforceValue)) + ); + assert!( + options + .public_key + .extensions + .min_pin_length + .map_or(false, |min| min.0 == FourToSixtyThree::Four + && matches!(min.1, ExtensionInfo::AllowEnforceValue)) + ); + assert!( + options + .public_key + .extensions + .prf + .map_or(false, |prf| prf.first.is_empty() + && prf.second.is_some_and(|p| p.is_empty()) + && matches!(prf.ext_req, ExtensionReq::Allow)) + ); + Ok(()) + } + #[test] + fn key_options() -> Result<(), Error> { + let mut err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 16>>( + r#"{"bob":true}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..201], + *"unknown field `bob`, expected one of `rp`, `user`, `challenge`, `pubKeyCredParams`, `timeout`, `excludeCredentials`, `authenticatorSelection`, `hints`, `extensions`, `attestation`, `attestationFormats`" + ); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"attestation":"none","attestation":"none"}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..29], *"duplicate field `attestation`"); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"authenticatorSelection":{"authenticatorAttachment":"platform"},"hints":["client-device", "security-key"]}"#, + ).unwrap_err(); + assert_eq!( + err.to_string()[..96], + *"'platform' authenticator attachment modality must coincide with no hints or 'client-device' hint" + ); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"challenge":"AAAAAAAAAAAAAAAAAAAAAA"}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..41], + *"invalid type: Option value, expected null" + ); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"excludeCredentials":[{"type":"public-key","transports":["usb"],"id":"AAAAAAAAAAAAAAAAAAAAAA"}]}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..19], *"trailing characters"); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"attestation":"foo"}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..27], *"invalid value: string \"foo\""); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"attestationFormats":["none","none"]}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..96], + *"attestationFormats must be an empty sequence or contain exactly one string whose value is 'none'" + ); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"attestationFormats":["foo"]}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..42], + *"invalid value: string \"foo\", expected none" + ); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"timeout":0}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..50], + *"invalid value: integer `0`, expected a nonzero u32" + ); + err = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"timeout":4294967296}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..59], + *"invalid value: integer `4294967296`, expected a nonzero u32" + ); + let mut key = + serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>(r#"{}"#)?; + assert_eq!(key.rp_id, DEFAULT_RP_ID); + assert_eq!(key.user.name.as_ref(), "blank"); + assert!(key.user.display_name.is_none()); + assert_eq!(key.pub_key_cred_params.0, CoseAlgorithmIdentifiers::ALL.0); + assert_eq!(key.timeout, FIVE_MINUTES); + assert!( + matches!(key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(key.extensions.cred_props.is_none()); + assert!(matches!(key.extensions.cred_protect, CredProtect::None)); + assert!(key.extensions.min_pin_length.is_none()); + assert!(key.extensions.prf.is_none()); + key = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"rp":null,"user":null,"timeout":null,"excludeCredentials":null,"attestation":null,"attestationFormats":null,"authenticatorSelection":null,"extensions":null,"pubKeyCredParams":null,"hints":null,"challenge":null}"#, + )?; + assert_eq!(key.rp_id, DEFAULT_RP_ID); + assert_eq!(key.user.name.as_ref(), "blank"); + assert!(key.user.display_name.is_none()); + assert_eq!(key.pub_key_cred_params.0, CoseAlgorithmIdentifiers::ALL.0); + assert_eq!(key.timeout, FIVE_MINUTES); + assert!( + matches!(key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(key.extensions.cred_props.is_none()); + assert!(matches!(key.extensions.cred_protect, CredProtect::None)); + assert!(key.extensions.min_pin_length.is_none()); + assert!(key.extensions.prf.is_none()); + key = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"rp":{},"user":{},"excludeCredentials":[],"attestationFormats":[],"authenticatorSelection":{},"extensions":{},"pubKeyCredParams":[],"hints":[]}"#, + )?; + assert_eq!(key.rp_id, DEFAULT_RP_ID); + assert_eq!(key.user.name.as_ref(), "blank"); + assert!(key.user.display_name.is_none()); + assert_eq!(key.pub_key_cred_params.0, CoseAlgorithmIdentifiers::ALL.0); + assert!( + matches!(key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(key.extensions.cred_props.is_none()); + assert!(matches!(key.extensions.cred_protect, CredProtect::None)); + assert!(key.extensions.min_pin_length.is_none()); + assert!(key.extensions.prf.is_none()); + key = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"rp":{"name":null,"id":null},"user":{"name":null,"id":null,"displayName":null},"authenticatorSelection":{"residentKey":null,"requireResidentKey":null,"userVerification":null,"authenticatorAttachment":null},"extensions":{"credProps":null,"credentialProtectionPolicy":null,"enforceCredentialProtectionPolicy":null,"minPinLength":null,"prf":null}}"#, + )?; + assert_eq!(key.rp_id, DEFAULT_RP_ID); + assert_eq!(key.user.name.as_ref(), "blank"); + assert!(key.user.display_name.is_none()); + assert_eq!(key.pub_key_cred_params.0, CoseAlgorithmIdentifiers::ALL.0); + assert!( + matches!(key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + key.authenticator_selection.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + key.authenticator_selection.user_verification, + UserVerificationRequirement::Preferred + )); + assert!(key.extensions.cred_props.is_none()); + assert!(matches!(key.extensions.cred_protect, CredProtect::None)); + assert!(key.extensions.min_pin_length.is_none()); + assert!(key.extensions.prf.is_none()); + key = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"rp":{"name":"Example.com","id":"example.com"},"user":{"name":"bob","displayName":"Bob","id":"AA"},"timeout":300000,"excludeCredentials":[],"attestation":"none","attestationFormats":["none"],"authenticatorSelection":{"authenticatorAttachment":"cross-platform","residentKey":"required","requireResidentKey":true,"userVerification":"required"},"extensions":{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{"eval":{"first":"","second":""}}},"pubKeyCredParams":[{"type":"public-key","alg":-8}],"hints":["security-key"],"challenge":null}"#, + )?; + assert_eq!(key.rp_id.as_ref(), "example.com"); + assert_eq!(key.user.name.as_ref(), "bob"); + assert!( + key.user + .display_name + .map_or(false, |name| name.as_ref() == "Bob") + ); + assert_eq!( + key.pub_key_cred_params.0, + CoseAlgorithmIdentifiers::ALL + .remove(CoseAlgorithmIdentifier::Es256) + .remove(CoseAlgorithmIdentifier::Es384) + .remove(CoseAlgorithmIdentifier::Rs256) + .0 + ); + assert_eq!(key.timeout, FIVE_MINUTES); + assert!( + matches!(key.authenticator_selection.authenticator_attachment, AuthenticatorAttachmentReq::CrossPlatform(hint) if matches!(hint, CrossPlatformHint::SecurityKey)) + ); + assert!(matches!( + key.authenticator_selection.resident_key, + ResidentKeyRequirement::Required + )); + assert!(matches!( + key.authenticator_selection.user_verification, + UserVerificationRequirement::Required + )); + assert!( + key.extensions + .cred_props + .map_or(false, |req| matches!(req, ExtensionReq::Allow)) + ); + assert!( + matches!(key.extensions.cred_protect, CredProtect::UserVerificationRequired(enforce, info) if !enforce && matches!(info, ExtensionInfo::AllowEnforceValue)) + ); + assert!( + key.extensions + .min_pin_length + .map_or(false, |min| min.0 == FourToSixtyThree::Four + && matches!(min.1, ExtensionInfo::AllowEnforceValue)) + ); + assert!(key.extensions.prf.map_or(false, |prf| prf.first.is_empty() + && prf.second.is_some_and(|p| p.is_empty()) + && matches!(prf.ext_req, ExtensionReq::Allow))); + key = serde_json::from_str::<PublicKeyCredentialCreationOptionsOwned<'_, '_, 1>>( + r#"{"timeout":4294967295}"#, + )?; + assert_eq!(key.timeout, NonZeroU32::MAX); + Ok(()) + } + #[test] + fn extension() -> Result<(), Error> { + let mut err = serde_json::from_str::<ExtensionOwned>(r#"{"bob":true}"#).unwrap_err(); + assert_eq!( + err.to_string()[..138], + *"unknown field `bob`, expected one of `credProps`, `credentialProtectionPolicy`, `enforceCredentialProtectionPolicy`, `minPinLength`, `prf`" + ); + err = serde_json::from_str::<ExtensionOwned>(r#"{"credProps":true,"credProps":true}"#) + .unwrap_err(); + assert_eq!(err.to_string()[..27], *"duplicate field `credProps`"); + err = + serde_json::from_str::<ExtensionOwned>(r#"{"enforceCredentialProtectionPolicy":null}"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..84], + *"'enforceCredentialProtectionPolicy' must not exist when 'credentialProtectionPolicy'" + ); + err = serde_json::from_str::<ExtensionOwned>( + r#"{"enforceCredentialProtectionPolicy":false,"credentialProtectionPolicy":null}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..103], + *"'enforceCredentialProtectionPolicy' must be null or not exist when 'credentialProtectionPolicy' is null" + ); + let mut ext = serde_json::from_str::<ExtensionOwned>( + r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{"eval":{"first":"","second":""}}}"#, + )?; + assert!( + ext.cred_props + .map_or(false, |props| matches!(props, ExtensionReq::Allow)) + ); + assert!( + matches!(ext.cred_protect, CredProtect::UserVerificationRequired(enforce, info) if !enforce && matches!(info, ExtensionInfo::AllowEnforceValue)) + ); + assert!( + ext.min_pin_length + .map_or(false, |min| min.0 == FourToSixtyThree::Four + && matches!(min.1, ExtensionInfo::AllowEnforceValue)) + ); + assert!(ext.prf.map_or(false, |prf| prf.first.is_empty() + && prf.second.is_some_and(|v| v.is_empty()) + && matches!(prf.ext_req, ExtensionReq::Allow))); + ext = serde_json::from_str::<ExtensionOwned>( + r#"{"credProps":null,"credentialProtectionPolicy":null,"enforceCredentialProtectionPolicy":null,"minPinLength":null,"prf":null}"#, + )?; + assert!(ext.cred_props.is_none()); + assert!(matches!(ext.cred_protect, CredProtect::None)); + assert!(ext.min_pin_length.is_none()); + assert!(ext.prf.is_none()); + ext = serde_json::from_str::<ExtensionOwned>(r#"{}"#)?; + assert!(ext.cred_props.is_none()); + assert!(matches!(ext.cred_protect, CredProtect::None)); + assert!(ext.min_pin_length.is_none()); + assert!(ext.prf.is_none()); + ext = serde_json::from_str::<ExtensionOwned>(r#"{"credentialProtectionPolicy":null}"#)?; + assert!(matches!(ext.cred_protect, CredProtect::None)); + ext = serde_json::from_str::<ExtensionOwned>( + r#"{"credentialProtectionPolicy":"userVerificationOptional"}"#, + )?; + assert!( + matches!(ext.cred_protect, CredProtect::UserVerificationOptional(enforce, info) if !enforce && matches!(info, ExtensionInfo::AllowEnforceValue)) + ); + ext = serde_json::from_str::<ExtensionOwned>( + r#"{"credentialProtectionPolicy":"userVerificationOptionalWithCredentialIDList","enforceCredentialProtectionPolicy":null}"#, + )?; + assert!( + matches!(ext.cred_protect, CredProtect::UserVerificationOptionalWithCredentialIdList(enforce, info) if !enforce && matches!(info, ExtensionInfo::AllowEnforceValue)) + ); + Ok(()) + } + #[test] + fn user_entity() -> Result<(), Error> { + let mut err = serde_json::from_str::<PublicKeyCredentialUserEntityOwned<'_, '_, 16>>( + r#"{"bob":true}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..64], + *"unknown field `bob`, expected one of `id`, `name`, `displayName`" + ); + err = serde_json::from_str::<PublicKeyCredentialUserEntityOwned<'_, '_, 1>>( + r#"{"name":"bob","name":"bob"}"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..22], *"duplicate field `name`"); + let mut user = serde_json::from_str::<PublicKeyCredentialUserEntityOwned<'_, '_, 1>>( + r#"{"id":"AA","name":"bob","displayName":"Bob"}"#, + )?; + assert_eq!(user.id.as_slice(), [0; 1].as_slice()); + assert_eq!(user.name.as_ref(), "bob"); + assert_eq!(user.display_name.as_ref().map(|v| v.as_ref()), Some("Bob")); + user = serde_json::from_str::<PublicKeyCredentialUserEntityOwned<'_, '_, 1>>( + r#"{"id":null,"name":null,"displayName":null}"#, + )?; + assert_eq!(user.name.as_ref(), "blank"); + assert!(user.display_name.is_none()); + user = serde_json::from_str::<PublicKeyCredentialUserEntityOwned<'_, '_, 1>>(r#"{}"#)?; + assert_eq!(user.name.as_ref(), "blank"); + assert!(user.display_name.is_none()); + Ok(()) + } + #[test] + fn auth_crit() -> Result<(), Error> { + let mut err = + serde_json::from_str::<AuthenticatorSelectionCriteria>(r#"null"#).unwrap_err(); + assert_eq!( + err.to_string()[..59], + *"invalid type: null, expected AuthenticatorSelectionCriteria" + ); + err = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"required","requireResidentKey":false}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..62], + *"'residentKey' is 'required', but 'requireResidentKey' is false" + ); + err = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"preferred","requireResidentKey":true}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..65], + *"'residentKey' is not 'required', but 'requireResidentKey' is true" + ); + err = + serde_json::from_str::<AuthenticatorSelectionCriteria>(r#"{"residentKey":"prefered"}"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..84], + *"invalid value: string \"prefered\", expected 'required', 'discouraged', or 'preferred'" + ); + err = + serde_json::from_str::<AuthenticatorSelectionCriteria>(r#"{"bob":true}"#).unwrap_err(); + assert_eq!( + err.to_string()[..119], + *"unknown field `bob`, expected one of `authenticatorAttachment`, `residentKey`, `requireResidentKey`, `userVerification`" + ); + err = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"requireResidentKey":true,"requireResidentKey":true}"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..36], + *"duplicate field `requireResidentKey`" + ); + let mut crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"authenticatorAttachment":"platform","residentKey":"required","requireResidentKey":true,"userVerification":"required"}"#, + )?; + assert!( + matches!(crit.authenticator_attachment, AuthenticatorAttachmentReq::Platform(hint) if matches!(hint, PlatformHint::None)) + ); + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Required + )); + assert!(matches!( + crit.user_verification, + UserVerificationRequirement::Required + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"authenticatorAttachment":null,"residentKey":null,"requireResidentKey":null,"userVerification":null}"#, + )?; + assert!( + matches!(crit.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + crit.user_verification, + UserVerificationRequirement::Preferred + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>(r#"{}"#)?; + assert!( + matches!(crit.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Discouraged + )); + assert!(matches!( + crit.user_verification, + UserVerificationRequirement::Preferred + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"preferred","requireResidentKey":false}"#, + )?; + assert!( + matches!(crit.authenticator_attachment, AuthenticatorAttachmentReq::None(hint) if matches!(hint, Hint::None)) + ); + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Preferred + )); + assert!(matches!( + crit.user_verification, + UserVerificationRequirement::Preferred + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"preferred"}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Preferred + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"requireResidentKey":true}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Required + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"requireResidentKey":false}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Discouraged + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"required"}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Required + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"discouraged"}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Discouraged + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"discouraged","requireResidentKey":null}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Discouraged + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":"required","requireResidentKey":null}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Required + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":null,"requireResidentKey":true}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Required + )); + crit = serde_json::from_str::<AuthenticatorSelectionCriteria>( + r#"{"residentKey":null,"requireResidentKey":false}"#, + )?; + assert!(matches!( + crit.resident_key, + ResidentKeyRequirement::Discouraged + )); + Ok(()) + } + #[test] + fn cose_algs() -> Result<(), Error> { + let mut err = serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"null"#).unwrap_err(); + assert_eq!( + err.to_string()[..53], + *"invalid type: null, expected CoseAlgorithmIdentifiers" + ); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"[null]"#).unwrap_err(); + assert_eq!( + err.to_string()[..37], + *"invalid type: null, expected PubParam" + ); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"[{}]"#).unwrap_err(); + assert_eq!(err.to_string()[..19], *"missing field `alg`"); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>( + r#"[{"type":"public-key","alg":-7,"foo":true}]"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..45], + *"unknown field `foo`, expected `type` or `alg`" + ); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>( + r#"[{"type":"public-key","alg":-7,"alg":-7}]"#, + ) + .unwrap_err(); + assert_eq!(err.to_string()[..21], *"duplicate field `alg`"); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>( + r#"[{"type":"public-key","alg":null}]"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..52], + *"invalid type: null, expected CoseAlgorithmIdentifier" + ); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"[{"type":null,"alg":-8}]"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..39], + *"invalid type: null, expected public-key" + ); + err = + serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"[{"type":"public-key","alg":-6}]"#) + .unwrap_err(); + assert_eq!( + err.to_string()[..58], + *"invalid value: integer `-6`, expected -8, -7, -35, or -257" + ); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>( + r#"[{"type":"public-key","alg":-7},{"type":"public-key","alg":-7}]"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..49], + *"pubKeyCredParams contained duplicate Es256 values" + ); + err = serde_json::from_str::<CoseAlgorithmIdentifiers>( + r#"[{"type":"public-key","alg":-7},{"type":"public-key","alg":-8}]"#, + ) + .unwrap_err(); + assert_eq!( + err.to_string()[..63], + *"pubKeyCredParams contained EdDSA, but it wasn't the first value" + ); + let mut alg = serde_json::from_str::<CoseAlgorithmIdentifiers>( + r#"[{"type":"public-key","alg":-8},{"alg":-7}]"#, + )?; + assert!(alg.contains(CoseAlgorithmIdentifier::Eddsa)); + assert!(alg.contains(CoseAlgorithmIdentifier::Es256)); + assert!(!alg.contains(CoseAlgorithmIdentifier::Es384)); + assert!(!alg.contains(CoseAlgorithmIdentifier::Rs256)); + alg = serde_json::from_str::<CoseAlgorithmIdentifiers>(r#"[]"#)?; + assert!(alg.contains(CoseAlgorithmIdentifier::Eddsa)); + assert!(alg.contains(CoseAlgorithmIdentifier::Es256)); + assert!(alg.contains(CoseAlgorithmIdentifier::Es384)); + assert!(alg.contains(CoseAlgorithmIdentifier::Rs256)); + Ok(()) + } +} diff --git a/src/request/register/ser_server_state.rs b/src/request/register/ser_server_state.rs @@ -293,7 +293,7 @@ impl<const USER_LEN: usize> Encode for RegistrationServerState<USER_LEN> { } } /// Error returned from [`RegistrationServerState::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeRegistrationServerStateErr { /// Variant returned when there was trailing data after decoding a [`RegistrationServerState`]. TrailingData, diff --git a/src/request/ser.rs b/src/request/ser.rs @@ -1,10 +1,20 @@ use super::{ - Challenge, CredentialId, CredentialMediationRequirement, Hint, PrfInput, - PublicKeyCredentialDescriptor, RpId, UserVerificationRequirement, + super::response::ser::Base64DecodedVal, AsciiDomain, AsciiDomainStatic, Challenge, + CredentialId, CredentialMediationRequirement, ExtensionReq, Hint, PrfInput, + PublicKeyCredentialDescriptor, RpId, Url, UserVerificationRequirement, auth::PrfInputOwned, }; -use core::str; -use data_encoding::BASE64URL_NOPAD; -use serde::ser::{Serialize, SerializeSeq as _, SerializeStruct as _, Serializer}; +use core::{ + fmt::{self, Formatter}, + str::FromStr as _, +}; +use serde::{ + de::{Deserialize, Deserializer, Error, MapAccess, SeqAccess, Unexpected, Visitor}, + ser::{Serialize, SerializeSeq as _, SerializeStruct as _, Serializer}, +}; +/// `"required"`. +const REQUIRED: &str = "required"; +/// `"conditional"`. +const CONDITIONAL: &str = "conditional"; impl Serialize for CredentialMediationRequirement { /// Serializes `self` to conform with /// [`CredentialMediationRequirement`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). @@ -29,8 +39,8 @@ impl Serialize for CredentialMediationRequirement { S: Serializer, { serializer.serialize_str(match *self { - Self::Conditional => "conditional", - Self::Required => "required", + Self::Required => REQUIRED, + Self::Conditional => CONDITIONAL, }) } } @@ -53,12 +63,73 @@ impl Serialize for Challenge { where S: Serializer, { - serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str( + serializer.serialize_str(base64url_nopad::encode_buffer( self.as_array().as_slice(), [0; Self::BASE64_LEN].as_mut_slice(), )) } } +impl Serialize for AsciiDomain { + /// Serializes `self` as a [`prim@str`]. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::AsciiDomain; + /// assert_eq!( + /// serde_json::to_string(&AsciiDomain::try_from("www.example.com".to_owned()).unwrap()).unwrap(), + /// r#""www.example.com""# + /// ); + /// ``` + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_ref()) + } +} +impl Serialize for AsciiDomainStatic { + /// Serializes `self` as a [`prim@str`]. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::AsciiDomainStatic; + /// assert_eq!( + /// serde_json::to_string(&AsciiDomainStatic::new("www.example.com").unwrap()).unwrap(), + /// r#""www.example.com""# + /// ); + /// ``` + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} +impl Serialize for Url { + /// Serializes `self` as a [`prim@str`]. + /// + /// # Examples + /// + /// ``` + /// # use core::str::FromStr as _; + /// # use webauthn_rp::request::Url; + /// assert_eq!( + /// serde_json::to_string(&Url::from_str("ssh:foo").unwrap()).unwrap(), + /// r#""ssh:foo""# + /// ); + /// ``` + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_ref()) + } +} impl Serialize for RpId { /// Serializes `self` as a [`prim@str`]. /// @@ -172,6 +243,12 @@ impl Serialize for UserVerificationRequirement { }) } } +/// [`security-key`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialhints-security-key). +const SECURITY_KEY: &str = "security-key"; +/// [`client-device`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialhints-client-device). +const CLIENT_DEVICE: &str = "client-device"; +/// [`hybrid`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialhints-hybrid). +const HYBRID: &str = "hybrid"; impl Serialize for Hint { /// Serializes `self` to conform with /// [`hints`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-hints). @@ -251,12 +328,6 @@ impl Serialize for Hint { where S: Serializer, { - /// [`security-key`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialhints-security-key). - const SECURITY_KEY: &str = "security-key"; - /// [`client-device`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialhints-client-device). - const CLIENT_DEVICE: &str = "client-device"; - /// [`hybrid`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialhints-hybrid). - const HYBRID: &str = "hybrid"; let count = match *self { Self::None => 0, Self::SecurityKey | Self::ClientDevice | Self::Hybrid => 1, @@ -338,6 +409,10 @@ impl Serialize for Hint { }) } } +/// `"first"`. +const FIRST: &str = "first"; +/// `"second"`. +const SECOND: &str = "second"; impl Serialize for PrfInput<'_, '_> { /// Serializes `self` to conform with /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). @@ -368,14 +443,14 @@ impl Serialize for PrfInput<'_, '_> { // The max value is 1 + 1 = 2, so overflow is not an issue. .serialize_struct("PrfInput", 1 + usize::from(self.second.is_some())) .and_then(|mut ser| { - ser.serialize_field("first", BASE64URL_NOPAD.encode(self.first).as_str()) + ser.serialize_field(FIRST, base64url_nopad::encode(self.first).as_str()) .and_then(|()| { self.second .as_ref() .map_or(Ok(()), |second| { ser.serialize_field( - "second", - BASE64URL_NOPAD.encode(second).as_str(), + SECOND, + base64url_nopad::encode(second).as_str(), ) }) .and_then(|()| ser.end()) @@ -383,3 +458,492 @@ impl Serialize for PrfInput<'_, '_> { }) } } +impl<'de> Deserialize<'de> for PrfInputOwned { + /// Deserializes a `struct` based on + /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). + /// + /// [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first) is required and + /// must not be `null`. + /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second) is not required + /// and can be `null`. + /// + /// Note [`PrfInputOwned::ext_req`] is set to [`ExtensionReq::Allow`]. + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `PrfInputOwned`. + struct PrfInputOwnedVisitor; + impl<'d> Visitor<'d> for PrfInputOwnedVisitor { + type Value = PrfInputOwned; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("PrfInputOwned") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'d>, + { + /// Field for `PrfInputOwned`. + enum Field { + /// `first`. + First, + /// `second`. + Second, + } + 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, "'{FIRST}' or '{SECOND}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + FIRST => Ok(Field::First), + SECOND => Ok(Field::Second), + _ => Err(E::unknown_field(v, FIELDS)), + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + let mut fst = None; + let mut snd = None; + while let Some(key) = map.next_key()? { + match key { + Field::First => { + if fst.is_some() { + return Err(Error::duplicate_field(FIRST)); + } + fst = map + .next_value::<Base64DecodedVal>() + .map(|val| Some(val.0))?; + } + Field::Second => { + if snd.is_some() { + return Err(Error::duplicate_field(SECOND)); + } + snd = map + .next_value::<Option<Base64DecodedVal>>() + .map(|opt| Some(opt.map(|val| val.0)))?; + } + } + } + fst.ok_or_else(|| Error::missing_field(FIRST)) + .map(|first| PrfInputOwned { + first, + second: snd.flatten(), + ext_req: ExtensionReq::Allow, + }) + } + } + const FIELDS: &[&str; 2] = &[FIRST, SECOND]; + deserializer.deserialize_struct("PrfInputOwned", FIELDS, PrfInputOwnedVisitor) + } +} +impl<'de> Deserialize<'de> for AsciiDomain { + /// Deserializes [`String`] based on [`Self::try_from`]. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::AsciiDomain; + /// assert!(matches!( + /// serde_json::from_str::<AsciiDomain>(r#""example.com""#)?.as_ref(), + /// "example.com" + /// )); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + String::deserialize(deserializer).and_then(|dom| Self::try_from(dom).map_err(Error::custom)) + } +} +impl Deserialize<'static> for AsciiDomainStatic { + /// Deserializes [`prim@str`] based on [`Self::new`]. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::AsciiDomainStatic; + /// assert!(matches!( + /// serde_json::from_str::<AsciiDomainStatic>(r#""example.com""#)?.as_str(), + /// "example.com" + /// )); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'static>, + { + <&'static str>::deserialize(deserializer).and_then(|dom| { + Self::new(dom) + .ok_or_else(|| Error::custom("AsciiDomainStatic requires a valid ASCII domain")) + }) + } +} +impl<'de> Deserialize<'de> for Url { + /// Deserializes [`prim@str`] based on [`Self::from_str`]. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::Url; + /// assert!(matches!( + /// serde_json::from_str::<Url>(r#""ssh:foo""#)?.as_ref(), + /// "ssh:foo" + /// )); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `Url` + struct UrlVisitor; + impl Visitor<'_> for UrlVisitor { + type Value = Url; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("Url") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + Url::from_str(v).map_err(E::custom) + } + } + deserializer.deserialize_str(UrlVisitor) + } +} +impl<'de> Deserialize<'de> for RpId { + /// Deserializes a [`String`] based on [`Self::try_from`]. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::RpId; + /// assert!(matches!( + /// serde_json::from_str::<RpId>(r#""example.com""#)?.as_ref(), + /// "example.com" + /// )); + /// assert!(matches!( + /// serde_json::from_str::<RpId>(r#""ssh:foo""#)?.as_ref(), + /// "ssh:foo" + /// )); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + String::deserialize(deserializer).and_then(|dom| Self::try_from(dom).map_err(Error::custom)) + } +} +/// Helper for `Hint::deserialize`. +enum HintHelper { + /// `"security-key"` + SecurityKey, + /// `"client-device"` + ClientDevice, + /// `"hybrid"` + Hybrid, +} +impl<'de> Deserialize<'de> for HintHelper { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `HintHelper`. + struct HintHelperVisitor; + impl Visitor<'_> for HintHelperVisitor { + type Value = HintHelper; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "'{SECURITY_KEY}', '{CLIENT_DEVICE}', or '{HYBRID}'" + ) + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + SECURITY_KEY => Ok(HintHelper::SecurityKey), + CLIENT_DEVICE => Ok(HintHelper::ClientDevice), + HYBRID => Ok(HintHelper::Hybrid), + _ => Err(E::invalid_value( + Unexpected::Str(v), + &format!("'{SECURITY_KEY}', '{CLIENT_DEVICE}', or '{HYBRID}'").as_str(), + )), + } + } + } + deserializer.deserialize_str(HintHelperVisitor) + } +} +impl<'de> Deserialize<'de> for Hint { + /// Deserializes a sequence based on + /// [`hints`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-hints). + /// + /// Note duplicates and unknown values will lead to an error. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::Hint; + /// assert!( + /// matches!( + /// serde_json::from_str(r#"["security-key", "hybrid", "client-device"]"#)?, + /// Hint::SecurityKeyHybridClientDevice + /// ) + /// ); + /// assert!( + /// matches!( + /// serde_json::from_str(r#"["hybrid", "security-key", "client-device"]"#)?, + /// Hint::HybridSecurityKeyClientDevice + /// ) + /// ); + /// assert!( + /// matches!( + /// serde_json::from_str(r#"[]"#)?, + /// Hint::None + /// ) + /// ); + /// assert!( + /// serde_json::from_str::<Hint>(r#"["security-key", "hybrid", "hybrid"]"#).is_err() + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `Hint`. + struct HintVisitor; + impl<'d> Visitor<'d> for HintVisitor { + type Value = Hint; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("unique sequence of hints") + } + fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error> + where + A: SeqAccess<'d>, + { + let mut hint = Hint::None; + while let Some(elem) = seq.next_element::<HintHelper>()? { + hint = match elem { + HintHelper::SecurityKey => match hint { + Hint::None => Hint::SecurityKey, + Hint::SecurityKey + | Hint::SecurityKeyClientDevice + | Hint::ClientDeviceSecurityKey + | Hint::SecurityKeyHybrid + | Hint::HybridSecurityKey + | Hint::SecurityKeyClientDeviceHybrid + | Hint::SecurityKeyHybridClientDevice + | Hint::ClientDeviceSecurityKeyHybrid + | Hint::ClientDeviceHybridSecurityKey + | Hint::HybridSecurityKeyClientDevice + | Hint::HybridClientDeviceSecurityKey => { + return Err(Error::custom(format!( + "'{SECURITY_KEY}' hint appeared more than once" + ))); + } + Hint::ClientDevice => Hint::ClientDeviceSecurityKey, + Hint::Hybrid => Hint::HybridSecurityKey, + Hint::ClientDeviceHybrid => Hint::ClientDeviceHybridSecurityKey, + Hint::HybridClientDevice => Hint::HybridClientDeviceSecurityKey, + }, + HintHelper::ClientDevice => match hint { + Hint::None => Hint::ClientDevice, + Hint::SecurityKey => Hint::SecurityKeyClientDevice, + Hint::ClientDevice + | Hint::ClientDeviceSecurityKey + | Hint::SecurityKeyClientDevice + | Hint::ClientDeviceHybrid + | Hint::HybridClientDevice + | Hint::ClientDeviceSecurityKeyHybrid + | Hint::ClientDeviceHybridSecurityKey + | Hint::SecurityKeyClientDeviceHybrid + | Hint::SecurityKeyHybridClientDevice + | Hint::HybridClientDeviceSecurityKey + | Hint::HybridSecurityKeyClientDevice => { + return Err(Error::custom(format!( + "'{CLIENT_DEVICE}' hint appeared more than once" + ))); + } + Hint::Hybrid => Hint::HybridClientDevice, + Hint::SecurityKeyHybrid => Hint::SecurityKeyHybridClientDevice, + Hint::HybridSecurityKey => Hint::HybridSecurityKeyClientDevice, + }, + HintHelper::Hybrid => match hint { + Hint::None => Hint::Hybrid, + Hint::Hybrid + | Hint::HybridClientDevice + | Hint::ClientDeviceHybrid + | Hint::HybridSecurityKey + | Hint::SecurityKeyHybrid + | Hint::HybridClientDeviceSecurityKey + | Hint::HybridSecurityKeyClientDevice + | Hint::ClientDeviceHybridSecurityKey + | Hint::ClientDeviceSecurityKeyHybrid + | Hint::SecurityKeyHybridClientDevice + | Hint::SecurityKeyClientDeviceHybrid => { + return Err(Error::custom(format!( + "'{HYBRID}' hint appeared more than once" + ))); + } + Hint::ClientDevice => Hint::ClientDeviceHybrid, + Hint::SecurityKey => Hint::SecurityKeyHybrid, + Hint::ClientDeviceSecurityKey => Hint::ClientDeviceSecurityKeyHybrid, + Hint::SecurityKeyClientDevice => Hint::SecurityKeyClientDeviceHybrid, + }, + }; + } + Ok(hint) + } + } + deserializer.deserialize_seq(HintVisitor) + } +} +impl<'de> Deserialize<'de> for CredentialMediationRequirement { + /// Deserializes a [`prim@str`] based on + /// [`CredentialMediationRequirement`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::CredentialMediationRequirement; + /// assert!( + /// matches!( + /// serde_json::from_str(r#""required""#)?, + /// CredentialMediationRequirement::Required, + /// ) + /// ); + /// assert!( + /// matches!( + /// serde_json::from_str(r#""conditional""#)?, + /// CredentialMediationRequirement::Conditional, + /// ) + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + /// `Visitor` for `CredentialMediationRequirement`. + struct CredentialMediationRequirementVisitor; + impl Visitor<'_> for CredentialMediationRequirementVisitor { + type Value = CredentialMediationRequirement; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "'{REQUIRED}' or '{CONDITIONAL}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + match v { + REQUIRED => Ok(CredentialMediationRequirement::Required), + CONDITIONAL => Ok(CredentialMediationRequirement::Conditional), + _ => Err(E::invalid_value( + Unexpected::Str(v), + &format!("'{REQUIRED}' or '{CONDITIONAL}'").as_str(), + )), + } + } + } + deserializer.deserialize_str(CredentialMediationRequirementVisitor) + } +} +/// Helper to deserialize the prf extension. +pub(super) struct PrfHelper(pub PrfInputOwned); +impl<'e> Deserialize<'e> for PrfHelper { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'e>, + { + /// `Visitor` for `PrfHelper`. + struct PrfHelperVisitor; + impl<'f> Visitor<'f> for PrfHelperVisitor { + type Value = PrfHelper; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("Prf") + } + fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error> + where + A: MapAccess<'f>, + { + /// Field for `PrfHelper`. + struct Field; + impl<'g> Deserialize<'g> for Field { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'g>, + { + /// `Visitor` for `Field`. + struct FieldVisitor; + impl Visitor<'_> for FieldVisitor { + type Value = Field; + fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!(formatter, "'{EVAL}'") + } + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> + where + E: Error, + { + if v == EVAL { + Ok(Field) + } else { + Err(E::unknown_field(v, FIELDS)) + } + } + } + deserializer.deserialize_identifier(FieldVisitor) + } + } + map.next_key::<Field>().and_then(|opt_key| { + opt_key + .ok_or_else(|| Error::missing_field(EVAL)) + .and_then(|_k| { + map.next_value().and_then(|prf_input| { + map.next_key::<Field>().and_then(|opt_key2| { + opt_key2.map_or_else( + || Ok(PrfHelper(prf_input)), + |_k2| Err(Error::duplicate_field(EVAL)), + ) + }) + }) + }) + }) + } + } + /// `"eval"`. + const EVAL: &str = "eval"; + /// Fields for `PrfHelper` + const FIELDS: &[&str; 1] = &[EVAL]; + deserializer.deserialize_struct("Prf", FIELDS, PrfHelperVisitor) + } +} +/// Default RP ID to use containing the value `"example.invalid"` when an RP ID is not sent. +pub(super) const DEFAULT_RP_ID: RpId = + RpId::StaticDomain(AsciiDomainStatic::new("example.invalid").unwrap()); diff --git a/src/response.rs b/src/response.rs @@ -19,7 +19,6 @@ use core::{ hash::{Hash, Hasher}, str, }; -use data_encoding::BASE64URL_NOPAD; use rsa::sha2::{digest::OutputSizeUser as _, Sha256}; #[cfg(feature = "serde_relaxed")] use ser_relaxed::SerdeJsonErr; @@ -30,17 +29,15 @@ use ser_relaxed::SerdeJsonErr; /// /// ```no_run /// # use core::convert; -/// # use data_encoding::BASE64URL_NOPAD; /// # use webauthn_rp::{ /// # hash::hash_set::FixedCapHashSet, -/// # request::{auth::{error::InvalidTimeout, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, error::AsciiDomainErr, register::{UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, AsciiDomainStatic, BackupReq, RpId}, +/// # request::{auth::{error::InvalidTimeout, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, register::{UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, BackupReq, RpId}, /// # response::{auth::{error::AuthCeremonyErr, DiscoverableAuthentication64}, error::CollectedClientDataErr, register::{AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, CompressedPubKeyOwned, StaticState}, AuthenticatorAttachment, Backup, CollectedClientData, CredentialId}, /// # AuthenticatedCredential, CredentialErr /// # }; /// # #[derive(Debug)] /// # enum E { /// # CollectedClientData(CollectedClientDataErr), -/// # RpId(AsciiDomainErr), /// # InvalidTimeout(InvalidTimeout), /// # SerdeJson(serde_json::Error), /// # MissingUserHandle, @@ -49,11 +46,6 @@ use ser_relaxed::SerdeJsonErr; /// # Credential(CredentialErr), /// # AuthCeremony(AuthCeremonyErr), /// # } -/// # impl From<AsciiDomainErr> for E { -/// # fn from(value: AsciiDomainErr) -> Self { -/// # Self::RpId(value) -/// # } -/// # } /// # impl From<CollectedClientDataErr> for E { /// # fn from(value: CollectedClientDataErr) -> Self { /// # Self::CollectedClientData(value) @@ -79,7 +71,7 @@ use ser_relaxed::SerdeJsonErr; /// # Self::AuthCeremony(value) /// # } /// # } -/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +/// const RP_ID: &RpId = &RpId::from_static_domain("example.com").unwrap(); /// let mut ceremonies = FixedCapHashSet::new(128); /// let (server, client) = DiscoverableCredentialRequestOptions::passkey(RP_ID).start_ceremony()?; /// assert!( @@ -101,7 +93,7 @@ use ser_relaxed::SerdeJsonErr; /// # #[cfg(feature = "serde")] /// fn get_authentication_json(client: DiscoverableAuthenticationClientState<'_, '_, '_>) -> String { /// // ⋮ -/// # let client_data_json = BASE64URL_NOPAD.encode(serde_json::json!({ +/// # let client_data_json = base64url_nopad::encode(serde_json::json!({ /// # "type": "webauthn.get", /// # "challenge": client.options().public_key.challenge, /// # "origin": format!("https://{}", client.options().public_key.rp_id.as_ref()), @@ -153,27 +145,20 @@ pub mod error; /// /// ```no_run /// # use core::convert; -/// # use data_encoding::BASE64URL_NOPAD; /// # use webauthn_rp::{ /// # hash::hash_set::FixedCapHashSet, -/// # request::{register::{error::CreationOptionsErr, CredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationClientState, UserHandle, UserHandle64, USER_HANDLE_MAX_LEN, RegistrationVerificationOptions}, error::AsciiDomainErr, AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId}, +/// # request::{register::{error::CreationOptionsErr, CredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationClientState, UserHandle, UserHandle64, USER_HANDLE_MAX_LEN, RegistrationVerificationOptions}, PublicKeyCredentialDescriptor, RpId}, /// # response::{register::{error::RegCeremonyErr, Registration}, error::CollectedClientDataErr, CollectedClientData}, /// # RegisteredCredential /// # }; /// # #[derive(Debug)] /// # enum E { /// # CollectedClientData(CollectedClientDataErr), -/// # RpId(AsciiDomainErr), /// # CreationOptions(CreationOptionsErr), /// # SerdeJson(serde_json::Error), /// # MissingCeremony, /// # RegCeremony(RegCeremonyErr), /// # } -/// # impl From<AsciiDomainErr> for E { -/// # fn from(value: AsciiDomainErr) -> Self { -/// # Self::RpId(value) -/// # } -/// # } /// # impl From<CollectedClientDataErr> for E { /// # fn from(value: CollectedClientDataErr) -> Self { /// # Self::CollectedClientData(value) @@ -194,7 +179,7 @@ pub mod error; /// # Self::RegCeremony(value) /// # } /// # } -/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +/// const RP_ID: &RpId = &RpId::from_static_domain("example.com").unwrap(); /// # #[cfg(feature = "custom")] /// let mut ceremonies = FixedCapHashSet::new(128); /// # #[cfg(feature = "custom")] @@ -239,7 +224,7 @@ pub mod error; /// # #[cfg(feature = "serde")] /// fn get_registration_json(client: RegistrationClientState<'_, '_, '_, '_, '_, '_, USER_HANDLE_MAX_LEN>) -> String { /// // ⋮ -/// # let client_data_json = BASE64URL_NOPAD.encode(serde_json::json!({ +/// # let client_data_json = base64url_nopad::encode(serde_json::json!({ /// # "type": "webauthn.create", /// # "challenge": client.options().public_key.challenge, /// # "origin": format!("https://{}", client.options().public_key.rp_id.as_ref()), @@ -272,7 +257,7 @@ pub mod register; /// Contains functionality to (de)serialize data to/from a client. #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] #[cfg(feature = "serde")] -mod ser; +pub(crate) mod ser; /// Contains functionality to deserialize data from a client in a "relaxed" way. #[cfg_attr(docsrs, doc(cfg(feature = "serde_relaxed")))] #[cfg(feature = "serde_relaxed")] @@ -301,7 +286,7 @@ impl PartialEq<Backup> for &Backup { } } /// [`AuthenticatorTransport`](https://www.w3.org/TR/webauthn-3/#enumdef-authenticatortransport). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorTransport { /// [`ble`](https://www.w3.org/TR/webauthn-3/#dom-authenticatortransport-ble). Ble, @@ -1054,7 +1039,6 @@ impl<const R: bool> LimitedVerificationParser<R> { } impl<const R: bool> ClientDataJsonParser for LimitedVerificationParser<R> { type Err = CollectedClientDataErr; - #[expect(clippy::panic_in_result_fn, reason = "want to crash when there is a bug")] #[expect(clippy::little_endian_bytes, reason = "Challenge::serialize and this need to be consistent across architectures")] #[expect(clippy::too_many_lines, reason = "110 lines is fine")] fn parse(json: &[u8]) -> Result<CollectedClientData<'_>, Self::Err> { @@ -1102,8 +1086,7 @@ impl<const R: bool> ClientDataJsonParser for LimitedVerificationParser<R> { if chall_key == AFTER_TYPE { chall_key_rem.split_at_checked(Challenge::BASE64_LEN).ok_or(CollectedClientDataErr::Len).and_then(|(base64_chall, base64_chall_rem)| { let mut chall = [0; 16]; - BASE64URL_NOPAD.decode_mut(base64_chall, chall.as_mut_slice()).map_err(|_e| CollectedClientDataErr::Challenge).and_then(|chall_len| { - assert_eq!(chall_len, 16, "there is a bug in BASE64URL_NOPAD::decode_mut"); + base64url_nopad::decode_buffer_exact(base64_chall, chall.as_mut_slice()).map_err(|_e| CollectedClientDataErr::Challenge).and_then(|()| { base64_chall_rem.split_at_checked(AFTER_CHALLENGE.len()).ok_or(CollectedClientDataErr::Len).and_then(|(origin_key, origin_key_rem)| { if origin_key == AFTER_CHALLENGE { Self::parse_string(origin_key_rem).and_then(|(origin, origin_rem)| { @@ -1180,7 +1163,6 @@ 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> { @@ -1192,8 +1174,7 @@ impl<const R: bool> ClientDataJsonParser for LimitedVerificationParser<R> { // 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.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"); + base64url_nopad::decode_buffer_exact(chall_slice, chall.as_mut_slice()).map_err(|_e| CollectedClientDataErr::Challenge).map(|()| { SentChallenge(u128::from_le_bytes(chall)) }) }) diff --git a/src/response/auth.rs b/src/response/auth.rs @@ -55,7 +55,7 @@ pub mod ser_relaxed; /// /// Note while many authenticators that implement `prf` don't require `prf` to have been sent during registration /// (i.e., [`register::Extension::prf`]), it is recommended to do so for those authenticators that do require it. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HmacSecret { /// No `hmac-secret` response. /// diff --git a/src/response/auth/error.rs b/src/response/auth/error.rs @@ -32,7 +32,7 @@ use core::{ fmt::{self, Display, Formatter}, }; /// Error returned in [`AuthenticatorDataErr::AuthenticatorExtension`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorExtensionOutputErr { /// The `slice` had an invalid length. Len, @@ -64,7 +64,7 @@ impl Display for AuthenticatorExtensionOutputErr { } impl Error for AuthenticatorExtensionOutputErr {} /// Error returned from [`AuthenticatorData::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorDataErr { /// The `slice` had an invalid length. Len, @@ -132,7 +132,7 @@ impl Display for AuthenticatorDataErr { } impl Error for AuthenticatorDataErr {} /// One or two. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OneOrTwo { /// One. One, @@ -149,7 +149,7 @@ impl Display for OneOrTwo { } } /// Error in [`AuthCeremonyErr::Extension`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExtensionErr { /// [`AuthenticatorExtensionOutput::hmac_secret`] was sent from the client, but [`Flag::user_verified`] /// was `false`. @@ -311,7 +311,7 @@ impl Error for AuthCeremonyErr {} /// This can be sent to the client when an authentication ceremony fails due to an unknown [`CredentialId`]. This /// can be due to the user deleting a credential on the RP's side but not deleting it on the authenticator. This /// response can be forwarded to the authenticator which can subsequently delete the credential. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct UnknownCredentialOptions<'rp, 'cred> { /// [`rpId`](https://www.w3.org/TR/webauthn-3/#dictdef-unknowncredentialoptions-rpid). pub rp_id: &'rp RpId, @@ -319,7 +319,7 @@ pub struct UnknownCredentialOptions<'rp, 'cred> { pub credential_id: CredentialId<&'cred [u8]>, } /// Error when a [`UserHandle`] does not exist that is required to. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct MissingUserHandleErr; impl Display for MissingUserHandleErr { #[inline] diff --git a/src/response/auth/ser.rs b/src/response/auth/ser.rs @@ -2,7 +2,7 @@ use super::{ super::{ super::response::ser::{Base64DecodedVal, PublicKeyCredential}, ser::{ - self, AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, + AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, ClientExtensions, }, }, @@ -16,7 +16,6 @@ use core::{ marker::PhantomData, str, }; -use data_encoding::BASE64URL_NOPAD; use rsa::sha2::{Sha256, digest::OutputSizeUser as _}; use serde::{ de::{Deserialize, Deserializer, Error, IgnoredAny, MapAccess, Unexpected, Visitor}, @@ -37,10 +36,6 @@ impl<'e> Deserialize<'e> for AuthData { formatter.write_str("AuthenticatorData") } #[expect( - clippy::panic_in_result_fn, - reason = "we want to crash when there is a bug" - )] - #[expect( clippy::arithmetic_side_effects, reason = "comment justifies its correctness" )] @@ -48,7 +43,7 @@ impl<'e> Deserialize<'e> for AuthData { where E: Error, { - ser::base64url_nopad_decode_len(v.len()) + base64url_nopad::decode_len(v.len()) .ok_or_else(|| E::invalid_value(Unexpected::Str(v), &"base64url-encoded value")) .and_then(|len| { // The decoded length is 3/4 of the encoded length, so overflow could only occur @@ -58,16 +53,9 @@ impl<'e> Deserialize<'e> for AuthData { // raw authenticator data by `AuthenticatorDataAssertion::new`. let mut auth_data = vec![0; len + Sha256::output_size()]; auth_data.truncate(len); - BASE64URL_NOPAD - .decode_mut(v.as_bytes(), auth_data.as_mut_slice()) - .map_err(|e| E::custom(e.error)) - .map(|dec_len| { - assert_eq!( - len, dec_len, - "there is a bug in BASE64URL_NOPAD::decode_mut" - ); - AuthData(auth_data) - }) + base64url_nopad::decode_buffer_exact(v.as_bytes(), auth_data.as_mut_slice()) + .map_err(E::custom) + .map(|()| AuthData(auth_data)) }) } } @@ -462,7 +450,6 @@ mod tests { super::super::request::register::USER_HANDLE_MIN_LEN, AuthenticatorAttachment, DiscoverableAuthentication, NonDiscoverableAuthentication, }; - use data_encoding::BASE64URL_NOPAD; use rsa::sha2::{Digest as _, Sha256}; use serde::de::{Error as _, Unexpected}; use serde_json::Error; @@ -511,10 +498,10 @@ mod tests { 0, 0, ]; - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(auth_data.as_slice()); - let b64_sig = BASE64URL_NOPAD.encode([].as_slice()); - let b64_user = BASE64URL_NOPAD.encode(b"\x00".as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(auth_data.as_slice()); + let b64_sig = base64url_nopad::encode([].as_slice()); + let b64_user = base64url_nopad::encode(b"\x00".as_slice()); // Base case is valid. assert!( serde_json::from_str::<DiscoverableAuthentication<USER_HANDLE_MIN_LEN>>( @@ -547,8 +534,7 @@ mod tests { // `id` and `rawId` mismatch. let mut err = Error::invalid_value( Unexpected::Bytes( - BASE64URL_NOPAD - .decode("ABABABABABABABABABABAA".as_bytes()) + base64url_nopad::decode("ABABABABABABABABABABAA".as_bytes()) .unwrap() .as_slice(), ), @@ -1311,10 +1297,10 @@ mod tests { 0, 0, ]; - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(auth_data.as_slice()); - let b64_sig = BASE64URL_NOPAD.encode([].as_slice()); - let b64_user = BASE64URL_NOPAD.encode(b"\x00".as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(auth_data.as_slice()); + let b64_sig = base64url_nopad::encode([].as_slice()); + let b64_user = base64url_nopad::encode(b"\x00".as_slice()); // Base case is valid. assert!( serde_json::from_str::<DiscoverableAuthentication<USER_HANDLE_MIN_LEN>>( diff --git a/src/response/auth/ser_relaxed.rs b/src/response/auth/ser_relaxed.rs @@ -19,8 +19,6 @@ use core::{ fmt::{self, Formatter}, marker::PhantomData, }; -#[cfg(doc)] -use data_encoding::BASE64URL_NOPAD; use serde::de::{Deserialize, Deserializer, Error, MapAccess, Visitor}; /// `newtype` around `ClientExtensionsOutputs` with a "relaxed" [`Self::deserialize`] implementation. struct ClientExtensionsOutputsRelaxed(pub ClientExtensionsOutputs); @@ -467,7 +465,6 @@ mod tests { DiscoverableAuthenticationRelaxed, DiscoverableCustomAuthentication, NonDiscoverableAuthenticationRelaxed, NonDiscoverableCustomAuthentication, }; - use data_encoding::BASE64URL_NOPAD; use rsa::sha2::{Digest as _, Sha256}; use serde::de::{Error as _, Unexpected}; use serde_json::Error; @@ -516,10 +513,10 @@ mod tests { 0, 0, ]; - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(auth_data.as_slice()); - let b64_sig = BASE64URL_NOPAD.encode([].as_slice()); - let b64_user = BASE64URL_NOPAD.encode(b"\x00".as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(auth_data.as_slice()); + let b64_sig = base64url_nopad::encode([].as_slice()); + let b64_user = base64url_nopad::encode(b"\x00".as_slice()); // Base case is valid. assert!( serde_json::from_str::<DiscoverableAuthenticationRelaxed<USER_HANDLE_MIN_LEN>>( @@ -552,8 +549,7 @@ mod tests { // `id` and `rawId` mismatch. let mut err = Error::invalid_value( Unexpected::Bytes( - BASE64URL_NOPAD - .decode("ABABABABABABABABABABAA".as_bytes()) + base64url_nopad::decode("ABABABABABABABABABABAA".as_bytes()) .unwrap() .as_slice(), ), @@ -1740,10 +1736,10 @@ mod tests { 0, 0, ]; - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(auth_data.as_slice()); - let b64_sig = BASE64URL_NOPAD.encode([].as_slice()); - let b64_user = BASE64URL_NOPAD.encode(b"\x00".as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(auth_data.as_slice()); + let b64_sig = base64url_nopad::encode([].as_slice()); + let b64_user = base64url_nopad::encode(b"\x00".as_slice()); // Base case is valid. assert!( serde_json::from_str::<DiscoverableAuthenticationRelaxed<USER_HANDLE_MIN_LEN>>( diff --git a/src/response/bin.rs b/src/response/bin.rs @@ -37,7 +37,7 @@ impl<'a> DecodeBuffer<'a> for Backup { } } /// Error returned from [`AuthTransports::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct DecodeAuthTransportsErr; impl Display for DecodeAuthTransportsErr { #[inline] diff --git a/src/response/error.rs b/src/response/error.rs @@ -1,7 +1,7 @@ extern crate alloc; +use super::{CRED_ID_MAX_LEN, CRED_ID_MIN_LEN}; #[cfg(doc)] use super::{Challenge, CollectedClientData, CredentialId}; -use super::{CRED_ID_MAX_LEN, CRED_ID_MIN_LEN}; use alloc::string::FromUtf8Error; use core::{ error::Error, @@ -10,7 +10,7 @@ use core::{ }; /// Error returned when a [`CredentialId`] does not have length inclusively between [`CRED_ID_MIN_LEN`] and /// [`CRED_ID_MAX_LEN`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CredentialIdErr; impl Display for CredentialIdErr { #[inline] @@ -23,7 +23,7 @@ impl Display for CredentialIdErr { } impl Error for CredentialIdErr {} /// Error returned from [`CollectedClientData::from_client_data_json`]. -#[derive(Debug)] +#[derive(Debug, Eq, PartialEq)] pub enum CollectedClientDataErr { /// The `slice` had invalid length. Len, diff --git a/src/response/register.rs b/src/response/register.rs @@ -29,7 +29,7 @@ use super::{ request::{ BackupReq, Challenge, UserVerificationRequirement, auth::{AuthenticationVerificationOptions, PublicKeyCredentialRequestOptions}, - register::{Extension, RegistrationServerState}, + register::{CoseAlgorithmIdentifier, Extension, RegistrationServerState}, }, }, AuthenticatorTransport, @@ -71,7 +71,7 @@ mod ser; #[cfg(feature = "serde_relaxed")] pub mod ser_relaxed; /// [`credentialProtectionPolicy`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#dom-authenticationextensionsclientinputs-credentialprotectionpolicy). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CredentialProtectionPolicy { /// `credProtect` was not sent. None, @@ -101,7 +101,7 @@ impl Display for CredentialProtectionPolicy { /// [`hmac-secret-mc`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-make-cred-extension). /// /// Note `hmac-secret-mc` can only exist if `hmac-secret` exists with a value of `true`. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HmacSecret { /// No `hmac-secret` extension. None, @@ -303,7 +303,7 @@ impl FromCbor<'_> for MinPinLength { .split_first() .ok_or(AuthenticatorExtensionOutputErr::Len) .and_then(|(&key_len, remaining)| match key_len.cmp(&24) { - Ordering::Less => FourToSixtyThree::new(key_len) + Ordering::Less => FourToSixtyThree::from_u8(key_len) .ok_or(AuthenticatorExtensionOutputErr::MinPinLengthValue) .map(|val| CborSuccess { value: Self::Val(val), @@ -314,7 +314,7 @@ impl FromCbor<'_> for MinPinLength { .ok_or(AuthenticatorExtensionOutputErr::Len) .and_then(|(&key_24, rem)| { if key_24 > 23 { - FourToSixtyThree::new(key_24) + FourToSixtyThree::from_u8(key_24) .ok_or( AuthenticatorExtensionOutputErr::MinPinLengthValue, ) @@ -2132,7 +2132,7 @@ impl<'a> FromCbor<'a> for UncompressedPubKey<'a> { const AAGUID_LEN: usize = 16; /// 16 bytes representing an /// [Authenticator Attestation Globally Unique Identifier (AAGUID)](https://www.w3.org/TR/webauthn-3/#aaguid). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Aaguid<'a>(&'a [u8]); impl<'a> Aaguid<'a> { /// Returns the contained data. @@ -2321,7 +2321,7 @@ impl FromCbor<'_> for NoneAttestation { } } /// A 64-byte slice that allegedly represents an Ed25519 signature. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Ed25519Signature<'a>(&'a [u8]); impl<'a> Ed25519Signature<'a> { /// Returns signature. @@ -2523,7 +2523,7 @@ impl<'a> FromCbor<'a> for RsaPkcs1v15Sig<'a> { } } /// [Packed](https://www.w3.org/TR/webauthn-3/#sctn-packed-attestation) signature. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Sig<'a> { /// Alleged Ed25519 signature. Ed25519(Ed25519Signature<'a>), @@ -2535,7 +2535,7 @@ pub enum Sig<'a> { Rs256(&'a [u8]), } /// [Packed](https://www.w3.org/TR/webauthn-3/#sctn-packed-attestation). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PackedAttestation<'a> { /// [Attestation signature](https://www.w3.org/TR/webauthn-3/#attestation-signature). pub signature: Sig<'a>, @@ -2660,7 +2660,7 @@ impl<'a> FromCbor<'a> for PackedAttestation<'a> { } } /// [Attestation statement format identifiers](https://www.w3.org/TR/webauthn-3/#sctn-attstn-fmt-ids). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AttestationFormat<'a> { /// [None](https://www.w3.org/TR/webauthn-3/#sctn-none-attestation). None, @@ -3192,7 +3192,7 @@ impl Response for Registration { } } /// [Attestation statement](https://www.w3.org/TR/webauthn-3/#attestation-statement). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Attestation { /// [None](https://www.w3.org/TR/webauthn-3/#none). None, @@ -3266,7 +3266,7 @@ impl Metadata<'_> { /// attestation: Attestation::None, /// aaguid: Aaguid::try_from([15; 16].as_slice())?, /// extensions: AuthenticatorExtensionOutputMetadata { - /// min_pin_length: Some(FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new"))), + /// min_pin_length: Some(FourToSixtyThree::Sixteen), /// }, /// client_extension_results: ClientExtensionsOutputsMetadata { /// cred_props: Some(CredentialPropertiesOutput { @@ -3331,9 +3331,9 @@ impl Metadata<'_> { None => buffer.extend_from_slice(b"null"), Some(pin) => { // Clearly correct. - let dig_1 = pin.value() / 10; + let dig_1 = pin.into_u8() / 10; // Clearly correct. - let dig_2 = pin.value() % 10; + let dig_2 = pin.into_u8() % 10; if dig_1 > 0 { // We simply add the appropriate offset which is `b'0` for decimal digits. // Overflow cannot occur since this maxes at `b'9'`. @@ -3479,31 +3479,53 @@ mod tests { AttestationFormat, AttestationObject, AuthDataContainer as _, AuthExtOutput as _, AuthTransports, AuthenticatorAttestation, AuthenticatorExtensionOutput, AuthenticatorExtensionOutputErr, Backup, CborSuccess, CredentialProtectionPolicy, - FromCbor as _, HmacSecret, Sig, UncompressedPubKey, + FourToSixtyThree, FromCbor as _, HmacSecret, Sig, UncompressedPubKey, cbor::{ BYTES, BYTES_INFO_24, MAP_1, MAP_2, MAP_3, MAP_4, SIMPLE_FALSE, SIMPLE_TRUE, TEXT_11, TEXT_12, TEXT_14, }, }; - use data_encoding::HEXLOWER; use ed25519_dalek::Verifier as _; use p256::ecdsa::{DerSignature as P256Sig, SigningKey as P256Key}; use rsa::sha2::{Digest as _, Sha256}; + fn hex_decode<const N: usize>(input: &[u8; N]) -> Vec<u8> { + /// Value to subtract from a lowercase hex digit. + const LOWER_OFFSET: u8 = b'a' - 10; + assert_eq!( + N & 1, + 0, + "hex_decode must be passed a reference to an array of even length" + ); + let mut data = Vec::with_capacity(N >> 1); + input.chunks_exact(2).fold((), |(), byte| { + let mut hex = byte[0]; + let val = match hex { + b'0'..=b'9' => hex - b'0', + b'a'..=b'f' => hex - LOWER_OFFSET, + _ => panic!("hex_decode must be passed a valid lowercase hexadecimal array"), + } << 4; + hex = byte[1]; + data.push( + val | match hex { + b'0'..=b'9' => hex - b'0', + b'a'..=b'f' => hex - LOWER_OFFSET, + _ => panic!("hex_decode must be passed a valid lowercase hexadecimal array"), + }, + ); + }); + data + } /// https://pr-preview.s3.amazonaws.com/w3c/webauthn/pull/2209.html#sctn-test-vectors-none-es256 #[test] fn es256_test_vector() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.org".to_owned())?); - let credential_private_key = HEXLOWER - .decode(b"6e68e7a58484a3264f66b77f5d6dc5bc36a47085b615c9727ab334e8c369c2ee".as_slice()) - .unwrap(); - let aaguid = HEXLOWER - .decode(b"8446ccb9ab1db374750b2367ff6f3a1f".as_slice()) - .unwrap(); - let credential_id = HEXLOWER - .decode(b"f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4".as_slice()) - .unwrap(); - let client_data_json = HEXLOWER.decode(b"7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22414d4d507434557878475453746e63647134313759447742466938767049612d7077386f4f755657345441222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20426b5165446a646354427258426941774a544c4535513d3d227d".as_slice()).unwrap(); - let attestation_object = HEXLOWER.decode(b"a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b559000000008446ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220".as_slice()).unwrap(); + let credential_private_key = + hex_decode(b"6e68e7a58484a3264f66b77f5d6dc5bc36a47085b615c9727ab334e8c369c2ee"); + let aaguid = hex_decode(b"8446ccb9ab1db374750b2367ff6f3a1f"); + let credential_id = + hex_decode(b"f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4"); + let client_data_json = hex_decode(b"7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a22414d4d507434557878475453746e63647134313759447742466938767049612d7077386f4f755657345441222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a20426b5165446a646354427258426941774a544c4535513d3d227d"); + let attestation_object = hex_decode(b"a363666d74646e6f6e656761747453746d74a068617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b559000000008446ccb9ab1db374750b2367ff6f3a1f0020f91f391db4c9b2fde0ea70189cba3fb63f579ba6122b33ad94ff3ec330084be4a5010203262001215820afefa16f97ca9b2d23eb86ccb64098d20db90856062eb249c33a9b672f26df61225820930a56b87a2fca66334b03458abf879717c12cc68ed73290af2e2664796b9220"); let key = *P256Key::from_slice(credential_private_key.as_slice()) .unwrap() .verifying_key(); @@ -3535,14 +3557,11 @@ mod tests { ); assert!(att_obj.data.auth_data.flags.user_present); assert!(matches!(att_obj.data.attestation, AttestationFormat::None)); - let authenticator_data = HEXLOWER - .decode( - b"bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000" - .as_slice(), - ) - .unwrap(); - let client_data_json_2 = HEXLOWER.decode(b"7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d".as_slice()).unwrap(); - let signature = HEXLOWER.decode(b"3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87".as_slice()).unwrap(); + let authenticator_data = hex_decode( + b"bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b51900000000", + ); + let client_data_json_2 = hex_decode(b"7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a224f63446e55685158756c5455506f334a5558543049393770767a7a59425039745a63685879617630314167222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73657d"); + let signature = hex_decode(b"3046022100f50a4e2e4409249c4a853ba361282f09841df4dd4547a13a87780218deffcd380221008480ac0f0b93538174f575bf11a1dd5d78c6e486013f937295ea13653e331e87"); let auth_assertion = NonDiscoverableAuthenticatorAssertion::<1>::without_user( client_data_json_2, authenticator_data, @@ -3569,17 +3588,13 @@ mod tests { #[test] fn es256_self_attest_test_vector() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.org".to_owned())?); - let credential_private_key = HEXLOWER - .decode(b"b4bbfa5d68e1693b6ef5a19a0e60ef7ee2cbcac81f7fec7006ac3a21e0c5116a".as_slice()) - .unwrap(); - let aaguid = HEXLOWER - .decode(b"df850e09db6afbdfab51697791506cfc".as_slice()) - .unwrap(); - let credential_id = HEXLOWER - .decode(b"455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58c".as_slice()) - .unwrap(); - let client_data_json = HEXLOWER.decode(b"7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a2265476e4374334c55745936366b336a506a796e6962506b31716e666644616966715a774c33417032392d55222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a205539685458764b453255526b4d6e625f3078594856673d3d227d".as_slice()).unwrap(); - let attestation_object = HEXLOWER.decode(b"a363666d74667061636b65646761747453746d74a263616c67266373696758483046022100ae045923ded832b844cae4d5fc864277c0dc114ad713e271af0f0d371bd3ac540221009077a088ed51a673951ad3ba2673d5029bab65b64f4ea67b234321f86fcfac5d68617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000df850e09db6afbdfab51697791506cfc0020455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58ca5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2".as_slice()).unwrap(); + let credential_private_key = + hex_decode(b"b4bbfa5d68e1693b6ef5a19a0e60ef7ee2cbcac81f7fec7006ac3a21e0c5116a"); + let aaguid = hex_decode(b"df850e09db6afbdfab51697791506cfc"); + let credential_id = + hex_decode(b"455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58c"); + let client_data_json = hex_decode(b"7b2274797065223a22776562617574686e2e637265617465222c226368616c6c656e6765223a2265476e4374334c55745936366b336a506a796e6962506b31716e666644616966715a774c33417032392d55222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a205539685458764b453255526b4d6e625f3078594856673d3d227d"); + let attestation_object = hex_decode(b"a363666d74667061636b65646761747453746d74a263616c67266373696758483046022100ae045923ded832b844cae4d5fc864277c0dc114ad713e271af0f0d371bd3ac540221009077a088ed51a673951ad3ba2673d5029bab65b64f4ea67b234321f86fcfac5d68617574684461746158a4bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b55d00000000df850e09db6afbdfab51697791506cfc0020455ef34e2043a87db3d4afeb39bbcb6cc32df9347c789a865ecdca129cbef58ca5010203262001215820eb151c8176b225cc651559fecf07af450fd85802046656b34c18f6cf193843c5225820927b8aa427a2be1b8834d233a2d34f61f13bfd44119c325d5896e183fee484f2"); let key = *P256Key::from_slice(credential_private_key.as_slice()) .unwrap() .verifying_key(); @@ -3616,14 +3631,11 @@ mod tests { } } }); - let authenticator_data = HEXLOWER - .decode( - b"bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b50900000000" - .as_slice(), - ) - .unwrap(); - let client_data_json_2 = HEXLOWER.decode(b"7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a225248696843784e534e493352594d45314f7731476d3132786e726b634a5f6666707637546e2d4a71386773222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a206754623533727a36456853576f6d58477a696d4331513d3d227d".as_slice()).unwrap(); - let signature = HEXLOWER.decode(b"3044022076691be76a8618976d9803c4cdc9b97d34a7af37e3bdc894a2bf54f040ffae850220448033a015296ffb09a762efd0d719a55346941e17e91ebf64c60d439d0b9744".as_slice()).unwrap(); + let authenticator_data = hex_decode( + b"bfabc37432958b063360d3ad6461c9c4735ae7f8edd46592a5e0f01452b2e4b50900000000", + ); + let client_data_json_2 = hex_decode(b"7b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a225248696843784e534e493352594d45314f7731476d3132786e726b634a5f6666707637546e2d4a71386773222c226f726967696e223a2268747470733a2f2f6578616d706c652e6f7267222c2263726f73734f726967696e223a66616c73652c22657874726144617461223a22636c69656e74446174614a534f4e206d617920626520657874656e6465642077697468206164646974696f6e616c206669656c647320696e20746865206675747572652c207375636820617320746869733a206754623533727a36456853576f6d58477a696d4331513d3d227d"); + let signature = hex_decode(b"3044022076691be76a8618976d9803c4cdc9b97d34a7af37e3bdc894a2bf54f040ffae850220448033a015296ffb09a762efd0d719a55346941e17e91ebf64c60d439d0b9744"); let auth_assertion = NonDiscoverableAuthenticatorAssertion::<1>::without_user( client_data_json_2, authenticator_data, @@ -3795,7 +3807,9 @@ mod tests { value.cred_protect, CredentialProtectionPolicy::UserVerificationOptional ) && matches!(value.hmac_secret, HmacSecret::One) - && value.min_pin_length.is_some_and(|pin| pin.value() == 5) + && value + .min_pin_length + .is_some_and(|pin| pin == FourToSixtyThree::Five) ); let opts = generate_auth_extensions(AuthExtOptions { cred_protect: Some(0), diff --git a/src/response/register/bin.rs b/src/response/register/bin.rs @@ -263,7 +263,7 @@ impl EncodeBuffer for Aaguid<'_> { } } /// Owned version of [`Aaguid`] that exists for [`MetadataOwned::aaguid`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AaguidOwned(pub [u8; super::AAGUID_LEN]); impl<'a: 'b, 'b> From<&'a AaguidOwned> for Aaguid<'b> { #[inline] @@ -279,7 +279,7 @@ impl<'a> DecodeBuffer<'a> for AaguidOwned { } impl EncodeBuffer for FourToSixtyThree { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { - self.value().encode_into_buffer(buffer); + self.into_u8().encode_into_buffer(buffer); } } impl EncodeBuffer for AuthenticatorExtensionOutputMetadata { @@ -290,7 +290,7 @@ impl EncodeBuffer for AuthenticatorExtensionOutputMetadata { impl<'a> DecodeBuffer<'a> for FourToSixtyThree { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { - u8::decode_from_buffer(data).and_then(|val| Self::new(val).ok_or(EncDecErr)) + u8::decode_from_buffer(data).and_then(|val| Self::from_u8(val).ok_or(EncDecErr)) } } impl<'a> DecodeBuffer<'a> for AuthenticatorExtensionOutputMetadata { @@ -394,7 +394,7 @@ impl<'a: 'b, 'b> From<&'a MetadataOwned> for Metadata<'b> { } } /// Error returned from [`MetadataOwned::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeMetadataOwnedErr { /// Variant returned when [`MetadataOwned::attestation`] could not be decoded. Attestation, @@ -497,7 +497,7 @@ impl Encode for StaticState<UncompressedPubKey<'_>> { } } /// Error returned from [`StaticState::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeStaticStateErr { /// Variant returned when [`StaticState::credential_public_key`] could not be decoded. CredentialPublicKey, @@ -592,7 +592,7 @@ impl Encode for DynamicState { } } /// Error returned from [`DynamicState::decode`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DecodeDynamicStateErr { /// Variant returned when [`DynamicState::user_verified`] could not be decoded. UserVerified, diff --git a/src/response/register/error.rs b/src/response/register/error.rs @@ -34,7 +34,7 @@ use core::{ fmt::{self, Display, Formatter}, }; /// Error returned from [`Ed25519PubKey::try_from`] when the `slice` is not 32-bytes in length. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Ed25519PubKeyErr; impl Display for Ed25519PubKeyErr { #[inline] @@ -44,7 +44,7 @@ impl Display for Ed25519PubKeyErr { } impl Error for Ed25519PubKeyErr {} /// Error returned from [`UncompressedP256PubKey::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum UncompressedP256PubKeyErr { /// Variant returned when the x-coordinate is not 32-bytes in length. X, @@ -63,7 +63,7 @@ impl Display for UncompressedP256PubKeyErr { impl Error for UncompressedP256PubKeyErr {} /// Error returned from [`CompressedP256PubKey::try_from`] when the x-coordinate /// is not exactly 32 bytes in length. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CompressedP256PubKeyErr; impl Display for CompressedP256PubKeyErr { #[inline] @@ -73,7 +73,7 @@ impl Display for CompressedP256PubKeyErr { } impl Error for CompressedP256PubKeyErr {} /// Error returned from [`UncompressedP384PubKey::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum UncompressedP384PubKeyErr { /// Variant returned when the x-coordinate is not 48-bytes in length. X, @@ -92,7 +92,7 @@ impl Display for UncompressedP384PubKeyErr { impl Error for UncompressedP384PubKeyErr {} /// Error returned from [`CompressedP384PubKey::try_from`] when the x-coordinate /// is not exactly 48 bytes in length. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct CompressedP384PubKeyErr; impl Display for CompressedP384PubKeyErr { #[inline] @@ -102,7 +102,7 @@ impl Display for CompressedP384PubKeyErr { } impl Error for CompressedP384PubKeyErr {} /// Error returned from [`RsaPubKey::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RsaPubKeyErr { /// Variant returned when the modulus has a leading 0. NLeading0, @@ -132,7 +132,7 @@ impl Display for RsaPubKeyErr { } impl Error for RsaPubKeyErr {} /// Error returned when an alleged public key is not valid. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PubKeyErr { /// Error when [`Ed25519PubKey`] is not valid. /// @@ -156,7 +156,7 @@ impl Display for PubKeyErr { } impl Error for PubKeyErr {} /// Error returned from [`Ed25519Signature::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Ed25519SignatureErr; impl Display for Ed25519SignatureErr { #[inline] @@ -167,7 +167,7 @@ impl Display for Ed25519SignatureErr { impl Error for Ed25519SignatureErr {} /// Error returned from [`Aaguid::try_from`] when the slice is not exactly /// 16-bytes in length. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct AaguidErr; impl Display for AaguidErr { #[inline] @@ -177,7 +177,7 @@ impl Display for AaguidErr { } impl Error for AaguidErr {} /// Error returned in [`AuthenticatorDataErr::AuthenticatorExtension`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorExtensionOutputErr { /// The `slice` had an invalid length. Len, @@ -213,7 +213,7 @@ impl Display for AuthenticatorExtensionOutputErr { } impl Error for AuthenticatorExtensionOutputErr {} /// Error returned in [`AttestedCredentialDataErr::CoseKey`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CoseKeyErr { /// The `slice` had an invalid length. Len, @@ -261,7 +261,7 @@ impl Display for CoseKeyErr { } impl Error for CoseKeyErr {} /// Error returned in [`AuthenticatorDataErr::AttestedCredential`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AttestedCredentialDataErr { /// The `slice` had an invalid length. Len, @@ -282,7 +282,7 @@ impl Display for AttestedCredentialDataErr { } impl Error for AttestedCredentialDataErr {} /// Error returned from [`AuthenticatorData::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthenticatorDataErr { /// The `slice` had an invalid length. Len, @@ -364,7 +364,7 @@ impl Display for AuthenticatorDataErr { } impl Error for AuthenticatorDataErr {} /// Error returned in [`AttestationObjectErr::Attestation`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AttestationErr { /// The `slice` had an invalid length. Len, @@ -424,7 +424,7 @@ impl Display for AttestationErr { } impl Error for AttestationErr {} /// Error returned by [`AttestationObject::try_from`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AttestationObjectErr { /// The `slice` had an invalid length. Len, @@ -475,7 +475,7 @@ impl Display for AttestationObjectErr { } impl Error for AttestationObjectErr {} /// Error in [`RegCeremonyErr::Extension`]. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExtensionErr { /// [`ClientExtensionsOutputs::cred_props`] was sent from the client but was not supposed to be. ForbiddenCredProps, diff --git a/src/response/register/ser.rs b/src/response/register/ser.rs @@ -2,7 +2,7 @@ use super::{ super::{ super::request::register::CoseAlgorithmIdentifier, ser::{ - self, AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, + AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, Base64DecodedVal, ClientExtensions, PublicKeyCredential, }, }, @@ -17,7 +17,6 @@ use core::{ marker::PhantomData, str, }; -use data_encoding::BASE64URL_NOPAD; use rsa::sha2::{Sha256, digest::OutputSizeUser as _}; use serde::de::{Deserialize, Deserializer, Error, IgnoredAny, MapAccess, Unexpected, Visitor}; /// Functionality for deserializing DER-encoded `SubjectPublicKeyInfo` _without_ making copies of data or @@ -754,10 +753,6 @@ impl<'e> Deserialize<'e> for AttObj { formatter.write_str("base64url-encoded attestation object") } #[expect( - clippy::panic_in_result_fn, - reason = "we want to crash when there is a bug" - )] - #[expect( clippy::arithmetic_side_effects, reason = "comment justifies their correctness" )] @@ -765,7 +760,7 @@ impl<'e> Deserialize<'e> for AttObj { where E: Error, { - ser::base64url_nopad_decode_len(v.len()) + base64url_nopad::decode_len(v.len()) .ok_or_else(|| E::invalid_value(Unexpected::Str(v), &"base64url-encoded value")) .and_then(|len| { // The decoded length is 3/4 of the encoded length, so overflow could only occur @@ -774,16 +769,9 @@ impl<'e> Deserialize<'e> for AttObj { // the raw attestation object by `AuthenticatorAttestation::new`. let mut att_obj = vec![0; len + Sha256::output_size()]; att_obj.truncate(len); - BASE64URL_NOPAD - .decode_mut(v.as_bytes(), &mut att_obj) - .map_err(|e| E::custom(e.error)) - .map(|dec_len| { - assert_eq!( - len, dec_len, - "there is a bug in BASE64URL_NOPAD::decode_mut" - ); - AttObj(att_obj) - }) + base64url_nopad::decode_buffer_exact(v.as_bytes(), &mut att_obj) + .map_err(E::custom) + .map(|()| AttObj(att_obj)) }) } } @@ -1403,7 +1391,6 @@ mod tests { CoseAlgorithmIdentifier, spki::SubjectPublicKeyInfo, }; - use data_encoding::BASE64URL_NOPAD; use ed25519_dalek::{VerifyingKey, pkcs8::EncodePublicKey}; use p256::{ EncodedPoint as P256Pt, PublicKey as P256PubKey, SecretKey as P256Key, @@ -1709,10 +1696,10 @@ mod tests { .unwrap() .to_public_key_der() .unwrap(); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 113..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 113..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<Registration>( @@ -1751,8 +1738,7 @@ mod tests { // `id` and `rawId` mismatch. let mut err = Error::invalid_value( Unexpected::Bytes( - BASE64URL_NOPAD - .decode("ABABABABABABABABABABAA".as_bytes()) + base64url_nopad::decode("ABABABABABABABABABABAA".as_bytes()) .unwrap() .as_slice(), ), @@ -1893,8 +1879,8 @@ mod tests { // `id` and the credential id in authenticator data mismatch. err = Error::invalid_value( Unexpected::Bytes( - BASE64URL_NOPAD - .decode("ABABABABABABABABABABAA".as_bytes()) + base64url_nopad + ::decode("ABABABABABABABABABABAA".as_bytes()) .unwrap() .as_slice(), ), @@ -1941,7 +1927,7 @@ mod tests { "rawId": "AAAAAAAAAAAAAAAAAAAAAA", "response": { "clientDataJSON": b64_cdata, - "authenticatorData": BASE64URL_NOPAD.encode(bad_auth.as_slice()), + "authenticatorData": base64url_nopad::encode(bad_auth.as_slice()), "transports": [], "publicKey": b64_key, "publicKeyAlgorithm": -8, @@ -2116,7 +2102,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(VerifyingKey::from_bytes(&[0; 32]).unwrap().to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(VerifyingKey::from_bytes(&[0; 32]).unwrap().to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -8, "attestationObject": b64_aobj, }, @@ -2982,10 +2968,10 @@ mod tests { .unwrap() .to_public_key_der() .unwrap(); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 113..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 113..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<Registration>( @@ -4099,10 +4085,10 @@ mod tests { att_obj[att_obj_len - 67..att_obj_len - 35] .copy_from_slice(enc_key.x().unwrap().as_slice()); att_obj[att_obj_len - 32..].copy_from_slice(enc_key.y().unwrap().as_slice()); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 148..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 148..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<Registration>( @@ -4252,7 +4238,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -7, "attestationObject": b64_aobj, }, @@ -4564,10 +4550,10 @@ mod tests { att_obj[att_obj_len - 99..att_obj_len - 51] .copy_from_slice(enc_key.x().unwrap().as_slice()); att_obj[att_obj_len - 48..].copy_from_slice(enc_key.y().unwrap().as_slice()); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 181..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 181..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<Registration>( @@ -4719,7 +4705,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -35, "attestationObject": b64_aobj, }, @@ -5297,10 +5283,10 @@ mod tests { let att_obj_len = att_obj.len(); att_obj[att_obj_len - 261..att_obj_len - 5] .copy_from_slice(key.n().to_bytes_be().as_slice()); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 343..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 343..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<Registration>( @@ -5508,7 +5494,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -257, "attestationObject": b64_aobj, }, diff --git a/src/response/register/ser_relaxed.rs b/src/response/register/ser_relaxed.rs @@ -442,7 +442,6 @@ mod tests { }, CustomRegistration, RegistrationRelaxed, }; - use data_encoding::BASE64URL_NOPAD; use ed25519_dalek::{VerifyingKey, pkcs8::EncodePublicKey}; use p256::{ EncodedPoint as P256Pt, PublicKey as P256PubKey, SecretKey as P256Key, @@ -619,10 +618,10 @@ mod tests { .unwrap() .to_public_key_der() .unwrap(); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 113..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 113..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<RegistrationRelaxed>( @@ -661,8 +660,7 @@ mod tests { // `id` and `rawId` mismatch. let mut err = Error::invalid_value( Unexpected::Bytes( - BASE64URL_NOPAD - .decode("ABABABABABABABABABABAA".as_bytes()) + base64url_nopad::decode("ABABABABABABABABABABAA".as_bytes()) .unwrap() .as_slice(), ), @@ -795,8 +793,8 @@ mod tests { // `id` and the credential id in authenticator data mismatch. err = Error::invalid_value( Unexpected::Bytes( - BASE64URL_NOPAD - .decode("ABABABABABABABABABABAA".as_bytes()) + base64url_nopad + ::decode("ABABABABABABABABABABAA".as_bytes()) .unwrap() .as_slice(), ), @@ -843,7 +841,7 @@ mod tests { "rawId": "AAAAAAAAAAAAAAAAAAAAAA", "response": { "clientDataJSON": b64_cdata, - "authenticatorData": BASE64URL_NOPAD.encode(bad_auth.as_slice()), + "authenticatorData": base64url_nopad::encode(bad_auth.as_slice()), "transports": [], "publicKey": b64_key, "publicKeyAlgorithm": -8, @@ -994,7 +992,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(VerifyingKey::from_bytes(&[0; 32]).unwrap().to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(VerifyingKey::from_bytes(&[0; 32]).unwrap().to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -8, "attestationObject": b64_aobj, }, @@ -2202,10 +2200,10 @@ mod tests { .unwrap() .to_public_key_der() .unwrap(); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 113..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 113..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<RegistrationRelaxed>( @@ -3310,10 +3308,10 @@ mod tests { att_obj[att_obj_len - 67..att_obj_len - 35] .copy_from_slice(enc_key.x().unwrap().as_slice()); att_obj[att_obj_len - 32..].copy_from_slice(enc_key.y().unwrap().as_slice()); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 148..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 148..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<RegistrationRelaxed>( @@ -3454,7 +3452,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -7, "attestationObject": b64_aobj, }, @@ -3783,10 +3781,10 @@ mod tests { att_obj[att_obj_len - 99..att_obj_len - 51] .copy_from_slice(enc_key.x().unwrap().as_slice()); att_obj[att_obj_len - 48..].copy_from_slice(enc_key.y().unwrap().as_slice()); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 181..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 181..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<RegistrationRelaxed>( @@ -3929,7 +3927,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -35, "attestationObject": b64_aobj, }, @@ -4529,10 +4527,10 @@ mod tests { let att_obj_len = att_obj.len(); att_obj[att_obj_len - 261..att_obj_len - 5] .copy_from_slice(key.n().to_bytes_be().as_slice()); - let b64_cdata = BASE64URL_NOPAD.encode(c_data_json.as_bytes()); - let b64_adata = BASE64URL_NOPAD.encode(&att_obj[att_obj.len() - 343..]); - let b64_key = BASE64URL_NOPAD.encode(pub_key.as_bytes()); - let b64_aobj = BASE64URL_NOPAD.encode(att_obj.as_slice()); + let b64_cdata = base64url_nopad::encode(c_data_json.as_bytes()); + let b64_adata = base64url_nopad::encode(&att_obj[att_obj.len() - 343..]); + let b64_key = base64url_nopad::encode(pub_key.as_bytes()); + let b64_aobj = base64url_nopad::encode(att_obj.as_slice()); // Base case is valid. assert!( serde_json::from_str::<RegistrationRelaxed>( @@ -4731,7 +4729,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKey": BASE64URL_NOPAD.encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), + "publicKey": base64url_nopad::encode(bad_pub_key.to_public_key_der().unwrap().as_bytes()), "publicKeyAlgorithm": -257, "attestationObject": b64_aobj, }, diff --git a/src/response/ser.rs b/src/response/ser.rs @@ -8,7 +8,6 @@ use core::{ fmt::{self, Formatter}, marker::PhantomData, }; -use data_encoding::BASE64URL_NOPAD; use serde::{ de::{Deserialize, Deserializer, Error, IgnoredAny, MapAccess, SeqAccess, Unexpected, Visitor}, ser::{Serialize, SerializeSeq as _, SerializeStruct as _, Serializer}, @@ -247,7 +246,7 @@ impl<T: AsRef<[u8]>> Serialize for CredentialId<T> { where S: Serializer, { - serializer.serialize_str(BASE64URL_NOPAD.encode(self.0.as_ref()).as_str()) + serializer.serialize_str(base64url_nopad::encode(self.0.as_ref()).as_str()) } } impl<'de> Deserialize<'de> for CredentialId<Vec<u8>> { @@ -282,12 +281,11 @@ impl<'de> Deserialize<'de> for CredentialId<Vec<u8>> { E: Error, { /// Minimum possible encoded length of a `CredentialId`. - const MIN_LEN: usize = crate::base64url_nopad_len(super::CRED_ID_MIN_LEN); + const MIN_LEN: usize = base64url_nopad::encode_len(super::CRED_ID_MIN_LEN); /// Maximum possible encoded length of a `CredentialId`. - const MAX_LEN: usize = crate::base64url_nopad_len(super::CRED_ID_MAX_LEN); + const MAX_LEN: usize = base64url_nopad::encode_len(super::CRED_ID_MAX_LEN); if (MIN_LEN..=MAX_LEN).contains(&v.len()) { - BASE64URL_NOPAD - .decode(v.as_bytes()) + base64url_nopad::decode(v.as_bytes()) .map_err(E::custom) .map(CredentialId) } else { @@ -360,7 +358,7 @@ impl<'de> Deserialize<'de> for AuthenticatorAttachment { } } /// Container of data that was encoded in base64url. -pub(super) struct Base64DecodedVal(pub Vec<u8>); +pub(crate) struct Base64DecodedVal(pub Vec<u8>); impl<'de> Deserialize<'de> for Base64DecodedVal { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where @@ -377,8 +375,7 @@ impl<'de> Deserialize<'de> for Base64DecodedVal { where E: Error, { - BASE64URL_NOPAD - .decode(v.as_bytes()) + base64url_nopad::decode(v.as_bytes()) .map_err(E::custom) .map(Base64DecodedVal) } @@ -417,23 +414,15 @@ impl<'de> Deserialize<'de> for SentChallenge { "base64 encoding of the 16-byte challenge in a URL safe way without padding", ) } - #[expect( - clippy::panic_in_result_fn, - reason = "we want to crash when there is a bug" - )] fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E> where E: Error, { if v.len() == Challenge::BASE64_LEN { let mut data = [0; 16]; - BASE64URL_NOPAD - .decode_mut(v, data.as_mut_slice()) - .map_err(|err| E::custom(err.error)) - .map(|len| { - assert_eq!(len, 16, "there is a bug in BASE64URL_NOPAD::decode_mut"); - SentChallenge::from_array(data) - }) + base64url_nopad::decode_buffer_exact(v, data.as_mut_slice()) + .map_err(E::custom) + .map(|()| SentChallenge::from_array(data)) } else { Err(E::invalid_value( Unexpected::Bytes(v), @@ -525,7 +514,7 @@ pub(super) struct PublicKeyCredential<const RELAXED: bool, const REG: bool, Auth pub client_extension_results: Ext, } /// Deserializes the value for type. -pub(super) struct Type; +pub(crate) struct Type; impl<'e> Deserialize<'e> for Type { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where @@ -923,7 +912,7 @@ where } } /// JSON `null`. -struct Null; +pub(crate) struct Null; impl<'de> Deserialize<'de> for Null { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where @@ -1198,66 +1187,3 @@ where ) } } -/// Calculates the number of bytes a base64url-encoded input of length `n` bytes will be decoded into. -/// -/// `Some` is returned iff `n` is a valid input length. -/// -/// Note `n` must not only be a valid length mathematically but also represent a possible allocation of that -/// many bytes. Since only allocations <= [`isize::MAX`] are possible, this will always return `None` when -/// `n > isize::MAX`. -#[expect( - clippy::arithmetic_side_effects, - clippy::as_conversions, - reason = "proof and comment justifies their correctness" -)] -pub(super) const fn base64url_nopad_decode_len(n: usize) -> Option<usize> { - // 64^n is the number of distinct values of the input. Let the decoded output be O. - // There are 256 possible values each byte in O can be; thus we must find - // the maximum nonnegative integer m such that: - // 256^m = (2^8)^m = 2^(8m) <= 64^n = (2^6)^n = 2^(6n) - // <==> - // lg(2^(8m)) = 8m <= lg(2^(6n)) = 6n lg is defined on all positive reals which 2^(8m) and 2^(6n) are - // <==> - // m <= 6n/8 = 3n/4 - // Clearly that corresponds to m = ⌊3n/4⌋. - // From the proof in `crate::base64url_nopad_len`, we know that n is a valid length - // iff n ≢ 1 (mod 4) and n <= isize::MAX. - // We claim ⌊3n/4⌋ = 3⌊n/4⌋ + ⌊3(n mod 4)/4⌋. - // Proof: - // There are three partitions for n: - // (1) 4i = n ≡ 0 (mod 4) for some integer i - // <==> - // ⌊3n/4⌋ = ⌊3(4i)/4⌋ = ⌊3i⌋ = 3i = 3⌊i⌋ = 3⌊4i/4⌋ = 3⌊n/4⌋ + 0 = 3⌊n/4⌋ + ⌊3(0)/4⌋ = 3⌊n/4⌋ + ⌊3(n mod 4)/4⌋ - // (2) 4i + 2 = n ≡ 2 (mod 4) for some integer i - // <==> - // ⌊3n/4⌋ = ⌊3(4i + 2)/4⌋ = ⌊3i + 6/4⌋ = 3i + ⌊6/4⌋ = 3i + 1 = 3⌊i⌋ + ⌊3(2)/4⌋ - // = 3⌊(4i + 2)/4⌋ + ⌊3((4i + 2) mod 4)/4⌋ - // = 3⌊n/4⌋ + ⌊3(n mod 4)/4⌋ - // (3) 4i + 3 = n ≡ 3 (mod 4) for some integer i - // <==> - // ⌊3n/4⌋ = ⌊3(4i + 3)/4⌋ = ⌊3i + 9/4⌋ = 3i + ⌊9/4⌋ = 3i + 2 = 3⌊i⌋ + ⌊3(3)/4⌋ - // = 3⌊(4i + 3)/4⌋ + ⌊3((4i + 3) mod 4)/4⌋ - // = 3⌊n/4⌋ + ⌊3(n mod 4)/4⌋ - // QED - // Naively implementing ⌊3n/4⌋ as (3 * n) / 3 can cause overflow due to `3 * n`; thus - // we implement the equivalent equation 3⌊n/4⌋ + ⌊3(n mod 4)/4⌋ instead: - // `(3 * (n / 4)) + ((3 * (n % 4)) / 4)` since none of the intermediate calculations suffer - // from overflow. - - // `isize::MAX > 0 = usize::MIN`; thus this conversion is lossless. - if n & 3 == 1 || n > isize::MAX as usize { - None - } else { - // n = 4quot + rem - let (quot, rem) = (n >> 2u8, n & 3); - // 3 * quot <= m < usize::MAX; thus the left operand of + is fine. - // rem <= 3 - // <==> - // 3rem <= 9 < usize::MAX; thus 3 * rem is fine. - // <==> - // ⌊3rem/4⌋ <= 3rem, so the right operand of + is fine. - // The sum is fine since - // m = ⌊3n/4⌋ = 3⌊n/4⌋ + ⌊3(n mod 4)/4⌋ = (3 * quot) + ((3 * rem) >> 2u8), and m < usize::MAX. - Some((3 * quot) + ((3 * rem) >> 2u8)) - } -} diff --git a/src/response/ser_relaxed.rs b/src/response/ser_relaxed.rs @@ -13,8 +13,6 @@ use core::{ fmt::{self, Formatter}, marker::PhantomData, }; -#[cfg(doc)] -use data_encoding::BASE64URL_NOPAD; use serde::de::{Deserialize, Deserializer, Error, IgnoredAny, MapAccess, Unexpected, Visitor}; #[cfg(doc)] use serde_json::de;