webauthn_rp

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

commit 554b521f867d6cc1b00456b90dd59b7d92607fed
parent 78b64db0e7571cf9fb3f8db2ecc500447f6a50bc
Author: Zack Newman <zack@philomathiclife.com>
Date:   Fri, 18 Jul 2025 17:53:35 -0600

small improvements

Diffstat:
MCargo.toml | 8++++----
Msrc/lib.rs | 168++++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/request.rs | 41+++++++++++++++++++++++------------------
Msrc/request/auth.rs | 376++++++++++++++++++-------------------------------------------------------------
Msrc/request/auth/error.rs | 48++++++++++++++++++++++++++++++++----------------
Msrc/request/auth/ser.rs | 8++++----
Msrc/request/auth/ser_server_state.rs | 32+++++++++++++++++++++++++-------
Msrc/request/register.rs | 408++++++-------------------------------------------------------------------------
Msrc/request/register/ser.rs | 14++++----------
Msrc/request/register/ser_server_state.rs | 32++++++++++++++++++++++++++++----
Msrc/request/ser.rs | 14++------------
Msrc/request/ser_server_state.rs | 48+++++++++++++++++++++++++++++++++---------------
Msrc/response/auth/error.rs | 11++++++-----
Msrc/response/auth/ser.rs | 6+++---
Msrc/response/register/ser.rs | 4++--
Msrc/response/ser.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
16 files changed, 416 insertions(+), 882 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -97,7 +97,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] data-encoding = { version = "2.9.0", default-features = false } -ed25519-dalek = { version = "2.1.1", 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"] } @@ -105,15 +105,15 @@ precis-profiles = { version = "0.1.12", default-features = false } rand = { version = "0.9.1", 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.140", 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"] } -ed25519-dalek = { version = "2.1.1", default-features = false, features = ["alloc", "pkcs8"] } +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"] } -serde_json = { version = "1.0.140", default-features = false, features = ["preserve_order"] } +serde_json = { version = "1.0.141", default-features = false, features = ["preserve_order"] } ### FEATURES ################################################################# diff --git a/src/lib.rs b/src/lib.rs @@ -545,7 +545,7 @@ use crate::{ }; use crate::{ request::{ - auth::error::{InvalidTimeout, SecondFactorErr}, + auth::error::{InvalidTimeout, NonDiscoverableCredentialRequestOptionsErr}, error::{AsciiDomainErr, DomainOriginParseErr, PortParseErr, SchemeParseErr, UrlErr}, register::{ ResidentKeyRequirement, USER_HANDLE_MAX_LEN, UserHandle, @@ -1094,12 +1094,19 @@ impl<'cred, 'user, const USER_LEN: usize, PublicKey> ) } } +use response::register::{CompressedPubKeyBorrowed, CompressedPubKeyOwned}; /// `AuthenticatedCredential` based on a [`UserHandle64`]. pub type AuthenticatedCredential64<'cred, 'user, PublicKey> = AuthenticatedCredential<'cred, 'user, USER_HANDLE_MAX_LEN, PublicKey>; /// `AuthenticatedCredential` based on a [`UserHandle16`]. pub type AuthenticatedCredential16<'cred, 'user, PublicKey> = AuthenticatedCredential<'cred, 'user, 16, PublicKey>; +/// `AuthenticatedCredential` that owns the key data. +pub type AuthenticatedCredentialOwned<'cred, 'user, const USER_LEN: usize> = + AuthenticatedCredential<'cred, 'user, USER_LEN, CompressedPubKeyOwned>; +/// `AuthenticatedCredential` that borrows the key data. +pub type AuthenticatedCredentialBorrowed<'cred, 'user, 'key, const USER_LEN: usize> = + AuthenticatedCredential<'cred, 'user, USER_LEN, CompressedPubKeyBorrowed<'key>>; /// Convenience aggregate error that rolls up all errors into one. #[derive(Debug)] pub enum AggErr { @@ -1117,10 +1124,10 @@ pub enum AggErr { /// [`NonDiscoverableCredentialRequestOptions::start_ceremony`] /// error. InvalidTimeout(InvalidTimeout), - /// Variant when [`NonDiscoverableCredentialRequestOptions::second_factor`] errors. - SecondFactor(SecondFactorErr), /// Variant when [`CredentialCreationOptions::start_ceremony`] errors. CreationOptions(CreationOptionsErr), + /// Variant when [`NonDiscoverableCredentialRequestOptions::start_ceremony`] errors. + NonDiscoverableCredentialRequestOptions(NonDiscoverableCredentialRequestOptionsErr), /// Variant when [`Nickname::try_from`] errors. Nickname(NicknameErr), /// Variant when [`Username::try_from`] errors. @@ -1238,18 +1245,18 @@ impl From<InvalidTimeout> for AggErr { Self::InvalidTimeout(value) } } -impl From<SecondFactorErr> for AggErr { - #[inline] - fn from(value: SecondFactorErr) -> Self { - Self::SecondFactor(value) - } -} impl From<CreationOptionsErr> for AggErr { #[inline] fn from(value: CreationOptionsErr) -> Self { Self::CreationOptions(value) } } +impl From<NonDiscoverableCredentialRequestOptionsErr> for AggErr { + #[inline] + fn from(value: NonDiscoverableCredentialRequestOptionsErr) -> Self { + Self::NonDiscoverableCredentialRequestOptions(value) + } +} impl From<NicknameErr> for AggErr { #[inline] fn from(value: NicknameErr) -> Self { @@ -1410,8 +1417,8 @@ impl Display for AggErr { Self::DomainOrigin(ref err) => err.fmt(f), Self::Port(ref err) => err.fmt(f), Self::InvalidTimeout(err) => err.fmt(f), - Self::SecondFactor(err) => err.fmt(f), Self::CreationOptions(err) => err.fmt(f), + Self::NonDiscoverableCredentialRequestOptions(err) => err.fmt(f), Self::Nickname(err) => err.fmt(f), Self::Username(err) => err.fmt(f), Self::RegCeremony(ref err) => err.fmt(f), @@ -1455,17 +1462,17 @@ impl Display for AggErr { impl Error for AggErr {} /// Calculates the number of bytes needed to encode an input of length `n` bytes into base64url. /// -/// `Some` is returned iff the encoded length does not exceed [`isize::MAX`]. +/// # Panics +/// +/// `panics` iff `n > isize::MAX`. #[expect( clippy::arithmetic_side_effects, clippy::as_conversions, - clippy::cast_possible_wrap, - clippy::cast_sign_loss, clippy::integer_division, clippy::integer_division_remainder_used, reason = "proof and comment justifies their correctness" )] -const fn base64url_nopad_len(n: usize) -> Option<usize> { +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: @@ -1474,95 +1481,54 @@ const fn base64url_nopad_len(n: usize) -> Option<usize> { // 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⌉; thus: + // 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); - // Actual construction of the encoded output requires the allocation to take no more than `isize::MAX` - // bytes; thus we must detect overflow of it and not `usize::MAX`. - // isize::MAX = usize::MAX / 2 >= usize::MAX / 3; thus this `as` conversion is lossless. - match (quot as isize).checked_mul(4) { - // If multiplying by 4 caused overflow, then multiplying by 4 and adding by 3 would also. - None => None, - // This won't overflow since this maxes at `isize::MAX` since - // `n` <= ⌊3*isize::MAX/4⌋; thus `quot` <= ⌊isize::MAX/4⌋. - // `n` can be partitioned into 4 possibilities: - // (1) n ≡ 0 (mod 4) = 4quot + 0 - // (2) n ≡ 1 (mod 4) = 4quot + 1 - // (3) n ≡ 2 (mod 4) = 4quot + 2 - // (4) n ≡ 3 (mod 4) = 4quot + 3 - // For (1), rem is 0; thus 4quot + 0 = `n` which is fine. - // For (2), rem is 1; thus 4quot + 2 = n - 1 + 2 = n + 1 <= ⌊3*isize::MAX/4⌋ + 1 <= isize::MAX for - // isize::MAX > 0. Clearly `isize::MAX > 0`; otherwise we couldn't allocate anything. - // For (3), rem is 2; thus 4quot + 3 = n - 2 + 3 = n + 1 <= ⌊3*isize::MAX/4⌋ + 1 <= isize::MAX for - // isize::MAX > 0. Clearly `isize::MAX > 0`; otherwise we couldn't allocate anything. - // For (4), rem is 3; thus 4quot + 4 = n - 3 + 4 = n + 1 <= ⌊3*isize::MAX/4⌋ + 1 <= isize::MAX for - // isize::MAX > 0. Clearly `isize::MAX > 0`; otherwise we couldn't allocate anything. - // - // `val >= 0`; thus we can cast it to `usize` via `as` in a lossless way. - // Thus this is free from overflow, underflow, and a lossy conversion. - Some(val) => Some(val as usize + (4 * rem).div_ceil(3)), - } -} -/// 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`. -#[cfg(feature = "serde")] -#[expect( - clippy::arithmetic_side_effects, - clippy::as_conversions, - clippy::integer_division_remainder_used, - reason = "proof and comment justifies their correctness" -)] -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) + // quot << 2u8 <= m < usize::MAX; thus the left operand of + is fine. + // rem <= 2 // <==> - // lg(2^(8m)) = 8m <= lg(2^(6n)) = 6n lg is defined on all positive reals which 2^(8m) and 2^(6n) are + // 4rem <= 8 < usize::MAX; thus rem << 2u8 is fine. // <==> - // m <= 6n/8 = 3n/4 - // Clearly that corresponds to m = ⌊3n/4⌋. - // - // There are three partitions for m: - // (1) m ≡ 0 (mod 3) = 3i - // (2) m ≡ 1 (mod 3) = 3i + 1 - // (3) m ≡ 2 (mod 3) = 3i + 2 - // - // From `crate::base64url_nopad_len`, we know that the encoded length, n, of an input of length m is n = ⌈4m/3⌉. - // The encoded length of (1) is thus n = ⌈4(3i)/3⌉ = ⌈4i⌉ = 4i ≡ 0 (mod 4). - // The encoded length of (2) is thus n = ⌈4(3i + 1)/3⌉ = ⌈4i + 4/3⌉ = 4i + 2 ≡ 2 (mod 4). - // The encoded length of (3) is thus n = ⌈4(3i + 2)/3⌉ = ⌈4i + 8/3⌉ = 4i + 3 ≡ 3 (mod 4). - // - // Thus if n ≡ 1 (mod 4), it is never a valid length. - // - // Let n be the length of a possible encoded output of an input of length m. - // We know from above that n ≢ 1 (mod 4), this leaves three possibilities: - // (1) n ≡ 0 (mod 4) = 4i - // (2) n ≡ 2 (mod 4) = 4i + 2 - // (3) n ≡ 3 (mod 4) = 4i + 3 - // - // For (1) an input of length 3i is the inverse since ⌈4(3i)/3⌉ = 4i. - // For (2) an input of length 3i + 1 is the inverse since ⌈4(3i + 1)/3⌉ = ⌈4i + 4/3⌉ = 4i + 2. - // For (3) an input of length 3i + 2 is the inverse since ⌈4(3i + 2)/3⌉ = ⌈4i + 8/3⌉ = 4i + 3. - // - // Consequently n is a valid length of an encoded output iff n ≢ 1 (mod 4). - - // `isize::MAX >= 0 >= usize::MIN`; thus this conversion is lossless. - if n % 4 == 1 || n > isize::MAX as usize { - None - } else { - let (quot, rem) = (n >> 2u8, n % 4); - // 4quot + rem = n - // rem <= 3 - // 3rem <= 9 - // 3rem/4 <= 2 - // 3quot + 3rem/4 <= 4quot + rem - // Thus no operation causes overflow or underflow. - Some((3 * quot) + ((3 * rem) >> 2u8)) - } + // ⌈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 @@ -66,7 +66,7 @@ use url::Url as Uri; /// let creds = get_registered_credentials(&user_handle)?; /// # #[cfg(feature = "custom")] /// let (server_2, client_2) = -/// NonDiscoverableCredentialRequestOptions::second_factor(RP_ID, creds)?.start_ceremony()?; +/// NonDiscoverableCredentialRequestOptions::second_factor(RP_ID, creds).start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( /// ceremonies_2.insert_remove_all_expired(server_2).map_or(false, convert::identity) @@ -200,9 +200,8 @@ pub(super) mod ser_server_state; #[derive(Debug)] pub struct Challenge(u128); impl Challenge { - // This won't `panic` since 4/3 of 16 is less than `usize::MAX`. /// The number of bytes a `Challenge` takes to encode in base64url. - pub(super) const BASE64_LEN: usize = super::base64url_nopad_len(16).unwrap(); + pub(super) const BASE64_LEN: usize = super::base64url_nopad_len(16); /// Generates a random `Challenge`. /// /// # Examples @@ -1274,30 +1273,32 @@ impl Display for ExtensionInfo { } } /// [`CredentialMediationRequirement`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). +/// +/// Note [`silent`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-silent) +/// is not supported for WebAuthn credentials, and +/// [`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)] pub enum CredentialMediationRequirement { - /// [`silent`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-silent). - Silent, - /// [`optional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-optional). + /// [`required`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-required). + /// + /// This is the default mediation for ceremonies. #[default] - Optional, + Required, /// [`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 /// can be explicitly performed during the ceremony. Conditional, - /// [`required`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-required). - Required, } #[cfg(test)] impl PartialEq for CredentialMediationRequirement { fn eq(&self, other: &Self) -> bool { match *self { - Self::Silent => matches!(other, Self::Silent), - Self::Optional => matches!(other, Self::Optional), - Self::Conditional => matches!(other, Self::Conditional), Self::Required => matches!(other, Self::Required), + Self::Conditional => matches!(other, Self::Conditional), } } } @@ -1706,8 +1707,6 @@ trait Ceremony<const USER_LEN: usize, const DISCOVERABLE: bool> { } } } -/// `300_000` milliseconds is equal to five minutes. -pub(super) const THREE_HUNDRED_THOUSAND: NonZeroU32 = NonZeroU32::new(300_000).unwrap(); /// "Ceremonies" stored on the server that expire after a certain duration. /// /// Types like [`RegistrationServerState`] and [`DiscoverableAuthenticationServerState`] are based on [`Challenge`]s @@ -1737,6 +1736,12 @@ impl PartialEq for PrfInput<'_, '_> { self.first == other.first && self.second == other.second } } + +/// The number of milliseconds in 5 minutes. +/// +/// This is the recommended default timeout duration for ceremonies +/// [in the spec](https://www.w3.org/TR/webauthn-3/#sctn-timeout-recommended-range). +pub const FIVE_MINUTES: NonZeroU32 = NonZeroU32::new(300_000).unwrap(); #[cfg(test)] mod tests { use super::AsciiDomainStatic; @@ -2212,10 +2217,10 @@ mod tests { }), }, }); - let mut opts = NonDiscoverableCredentialRequestOptions::second_factor(RP_ID, creds)?; - opts.options().user_verification = UserVerificationRequirement::Required; - opts.options().challenge = Challenge(0); - opts.options().extensions = AuthExt { prf: None }; + let mut opts = NonDiscoverableCredentialRequestOptions::second_factor(RP_ID, creds); + opts.options.user_verification = UserVerificationRequirement::Required; + opts.options.challenge = Challenge(0); + opts.options.extensions = AuthExt { prf: None }; let client_data_json = br#"{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAssertion::new`] for more information. let mut authenticator_data = Vec::with_capacity(164); diff --git a/src/request/auth.rs b/src/request/auth.rs @@ -25,9 +25,9 @@ use super::{ }, }, BackupReq, Ceremony, CeremonyOptions, Challenge, CredentialId, CredentialMediationRequirement, - Credentials, ExtensionReq, Hint, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, - SentChallenge, THREE_HUNDRED_THOUSAND, TimedCeremony, UserVerificationRequirement, - auth::error::{InvalidTimeout, SecondFactorErr}, + Credentials, ExtensionReq, FIVE_MINUTES, Hint, Origin, PrfInput, PublicKeyCredentialDescriptor, + RpId, SentChallenge, TimedCeremony, UserVerificationRequirement, + auth::error::{InvalidTimeout, NonDiscoverableCredentialRequestOptionsErr}, }; use core::{ borrow::Borrow, @@ -289,7 +289,7 @@ impl From<Vec<PublicKeyCredentialDescriptor<Vec<u8>>>> for AllowedCredentials { } } /// The [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions) -/// to send to the client when authenticating a discoverable credentential. +/// to send to the client when authenticating a discoverable credential. /// /// Upon saving the [`DiscoverableAuthenticationServerState`] returned from [`Self::start_ceremony`], one MUST send /// [`DiscoverableAuthenticationClientState`] to the client ASAP. After receiving the newly created @@ -297,6 +297,10 @@ impl From<Vec<PublicKeyCredentialDescriptor<Vec<u8>>>> for AllowedCredentials { #[derive(Debug)] pub struct DiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { /// [`mediation`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). + /// + /// Note if this is [`CredentialMediationRequirement::Conditional`], user agents are instructed to not + /// enforce any timeout; as result, one may want to set [`PublicKeyCredentialRequestOptions::timeout`] to + /// [`NonZeroU32::MAX`]. pub mediation: CredentialMediationRequirement, /// `public-key` [credential type](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry). pub public_key: PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, @@ -386,7 +390,7 @@ impl<'rp_id, 'prf_first, 'prf_second> } } /// The [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions) -/// to send to the client when authenticating non-discoverable credententials. +/// to send to the client when authenticating non-discoverable credentials. /// /// Upon saving the [`NonDiscoverableAuthenticationServerState`] returned from [`Self::start_ceremony`], one MUST send /// [`NonDiscoverableAuthenticationClientState`] to the client ASAP. After receiving the newly created @@ -394,41 +398,19 @@ impl<'rp_id, 'prf_first, 'prf_second> #[derive(Debug)] pub struct NonDiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { /// [`mediation`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). - mediation: CredentialMediationRequirement, + pub mediation: CredentialMediationRequirement, /// [`PublicKeyCredentialRequestOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions). - options: PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, + pub options: PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, /// [`allowCredentials`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials). - allow_credentials: AllowedCredentials, + pub allow_credentials: AllowedCredentials, } impl<'rp_id, 'prf_first, 'prf_second> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { - /// Returns a mutable reference to the `CredentialMediationRequirement`. - #[inline] - pub const fn mediation(&mut self) -> &mut CredentialMediationRequirement { - &mut self.mediation - } - /// Returns a mutable reference to the configurable options. - #[inline] - pub const fn options( - &mut self, - ) -> &mut PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { - &mut self.options - } - /// Returns a reference to the [`AllowedCredential`]s. - #[inline] - #[must_use] - pub const fn allow_credentials(&self) -> &AllowedCredentials { - &self.allow_credentials - } /// Creates a `NonDiscoverableCredentialRequestOptions` containing - /// [`CredentialMediationRequirement::Optional`], + /// [`CredentialMediationRequirement::default`], /// [`PublicKeyCredentialRequestOptions::second_factor`], and the passed [`AllowedCredentials`]. /// - /// # Errors - /// - /// Errors iff `allow_credentials` is empty. - /// /// # Examples /// /// ``` @@ -461,8 +443,8 @@ impl<'rp_id, 'prf_first, 'prf_second> /// assert!(creds.push(PublicKeyCredentialDescriptor { id, transports }.into())); /// # #[cfg(all(feature = "bin", feature = "custom"))] /// assert_eq!( - /// NonDiscoverableCredentialRequestOptions::second_factor(&RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?), creds)? - /// .allow_credentials() + /// NonDiscoverableCredentialRequestOptions::second_factor(&RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?), creds) + /// .allow_credentials /// .len(), /// 1 /// ); @@ -470,18 +452,15 @@ impl<'rp_id, 'prf_first, 'prf_second> /// ``` #[expect(single_use_lifetimes, reason = "false positive")] #[inline] + #[must_use] pub fn second_factor<'a: 'rp_id>( rp_id: &'a RpId, allow_credentials: AllowedCredentials, - ) -> Result<Self, SecondFactorErr> { - if allow_credentials.is_empty() { - Err(SecondFactorErr) - } else { - Ok(Self { - mediation: CredentialMediationRequirement::default(), - options: PublicKeyCredentialRequestOptions::second_factor(rp_id), - allow_credentials, - }) + ) -> Self { + Self { + mediation: CredentialMediationRequirement::default(), + options: PublicKeyCredentialRequestOptions::second_factor(rp_id), + allow_credentials, } } /// Begins the [authentication ceremony](https://www.w3.org/TR/webauthn-3/#authentication-ceremony) consuming @@ -501,30 +480,36 @@ impl<'rp_id, 'prf_first, 'prf_second> NonDiscoverableAuthenticationServerState, NonDiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second>, ), - InvalidTimeout, + NonDiscoverableCredentialRequestOptionsErr, > { - #[cfg(not(feature = "serializable_server_state"))] - let res = Instant::now(); - #[cfg(feature = "serializable_server_state")] - let res = SystemTime::now(); - res.checked_add(Duration::from_millis( - NonZeroU64::from(self.options.timeout).get(), - )) - .ok_or(InvalidTimeout) - .map(|expiration| { - ( - NonDiscoverableAuthenticationServerState { - state: AuthenticationServerState { - challenge: SentChallenge(self.options.challenge.0), - user_verification: self.options.user_verification, - extensions: self.options.extensions.into(), - expiration, + if self.allow_credentials.is_empty() { + Err(NonDiscoverableCredentialRequestOptionsErr::EmptyAllowedCredentials) + } else if matches!(self.mediation, CredentialMediationRequirement::Conditional) { + Err(NonDiscoverableCredentialRequestOptionsErr::ConditionalMediationRequested) + } else { + #[cfg(not(feature = "serializable_server_state"))] + let res = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let res = SystemTime::now(); + res.checked_add(Duration::from_millis( + NonZeroU64::from(self.options.timeout).get(), + )) + .ok_or(NonDiscoverableCredentialRequestOptionsErr::InvalidTimeout) + .map(|expiration| { + ( + NonDiscoverableAuthenticationServerState { + state: AuthenticationServerState { + challenge: SentChallenge(self.options.challenge.0), + user_verification: self.options.user_verification, + extensions: self.options.extensions.into(), + expiration, + }, + allow_credentials: Vec::from(&self.allow_credentials), }, - allow_credentials: Vec::from(&self.allow_credentials), - }, - NonDiscoverableAuthenticationClientState(self), - ) - }) + NonDiscoverableAuthenticationClientState(self), + ) + }) + } } } /// The [`PublicKeyCredentialRequestOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions) @@ -554,7 +539,7 @@ pub struct PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { } impl<'rp_id> PublicKeyCredentialRequestOptions<'rp_id, '_, '_> { /// Creates a `PublicKeyCredentialRequestOptions` with [`Self::user_verification`] set to - /// [`UserVerificationRequirement::Required`] and [`Self::timeout`] set to 5 minutes, + /// [`UserVerificationRequirement::Required`] and [`Self::timeout`] set to [`FIVE_MINUTES`]. /// /// Note `rp_id` _must_ be the same as the [`PublicKeyCredentialCreationOptions::rp_id`] when the /// credential was registered. @@ -575,7 +560,7 @@ impl<'rp_id> PublicKeyCredentialRequestOptions<'rp_id, '_, '_> { pub fn passkey<'a: 'rp_id>(rp_id: &'a RpId) -> Self { Self { challenge: Challenge::new(), - timeout: THREE_HUNDRED_THOUSAND, + timeout: FIVE_MINUTES, rp_id, user_verification: UserVerificationRequirement::Required, hints: Hint::None, @@ -583,7 +568,7 @@ impl<'rp_id> PublicKeyCredentialRequestOptions<'rp_id, '_, '_> { } } /// Creates a `PublicKeyCredentialRequestOptions` with [`Self::user_verification`] set to - /// [`UserVerificationRequirement::Discouraged`] and [`Self::timeout`] set to 5 minutes. + /// [`UserVerificationRequirement::Discouraged`] and [`Self::timeout`] set to [`FIVE_MINUTES`]. /// /// Note `rp_id` _must_ be the same as the [`PublicKeyCredentialCreationOptions::rp_id`] when the /// credentials were registered. @@ -662,16 +647,6 @@ enum CredPrf { /// `prf.enabled` and `hmac_secret` are `true`. TrueTrueHmac, } -impl CredPrf { - /// Returns `true` iff `self` is allowed to have an `HmacSecret` response. - /// - /// Note many authenticators allow PRF to be used during authentication even when not requested during - /// registration even for authenticators (e.g., CTAP-based ones) that implement PRF on top of the `hmac-secret` - /// extension; thus we allow `Self::None` and `Self::TrueNoHmac`. - const fn is_prf_capable(self) -> bool { - matches!(self, Self::None | Self::TrueNoHmac | Self::TrueTrueHmac) - } -} /// `PrfInput` and `PrfInputOwned` without the actual data sent to reduce memory usage when storing /// [`DiscoverableAuthenticationServerState`] in an in-memory collection. #[derive(Clone, Copy, Debug)] @@ -702,20 +677,12 @@ impl ServerPrfInfo { HmacSecret::None => match self { Self::None => Ok(()), Self::One(req) | Self::Two(req) => { - if matches!(req, ExtensionReq::Allow) { - if cred_prf.is_prf_capable() { - Ok(()) - } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) - } + if matches!(req, ExtensionReq::Allow) + || !matches!(cred_prf, CredPrf::TrueTrueHmac) + { + Ok(()) } else { - match cred_prf { - CredPrf::None | CredPrf::TrueNoHmac => Ok(()), - CredPrf::FalseNoHmac | CredPrf::FalseFalseHmac => { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) - } - CredPrf::TrueTrueHmac => Err(ExtensionErr::MissingHmacSecret), - } + Err(ExtensionErr::MissingHmacSecret) } } }, @@ -723,21 +690,21 @@ impl ServerPrfInfo { Self::None => { if err_unsolicited { Err(ExtensionErr::ForbiddenHmacSecret) - } else if cred_prf.is_prf_capable() { + } else if matches!(cred_prf, CredPrf::None | CredPrf::TrueTrueHmac) { if user_verified { Ok(()) } else { Err(ExtensionErr::UserNotVerifiedHmacSecret) } } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + Err(ExtensionErr::HmacSecretForNonHmacSecretCredential) } } Self::One(_) => { - if cred_prf.is_prf_capable() { + if matches!(cred_prf, CredPrf::None | CredPrf::TrueTrueHmac) { Ok(()) } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + Err(ExtensionErr::HmacSecretForNonHmacSecretCredential) } } Self::Two(_) => Err(ExtensionErr::InvalidHmacSecretValue( @@ -749,14 +716,14 @@ impl ServerPrfInfo { Self::None => { if err_unsolicited { Err(ExtensionErr::ForbiddenHmacSecret) - } else if cred_prf.is_prf_capable() { + } else if matches!(cred_prf, CredPrf::None | CredPrf::TrueTrueHmac) { if user_verified { Ok(()) } else { Err(ExtensionErr::UserNotVerifiedHmacSecret) } } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + Err(ExtensionErr::HmacSecretForNonHmacSecretCredential) } } Self::One(_) => Err(ExtensionErr::InvalidHmacSecretValue( @@ -764,10 +731,10 @@ impl ServerPrfInfo { OneOrTwo::Two, )), Self::Two(_) => { - if cred_prf.is_prf_capable() { + if matches!(cred_prf, CredPrf::None | CredPrf::TrueTrueHmac) { Ok(()) } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + Err(ExtensionErr::HmacSecretForNonHmacSecretCredential) } } }, @@ -1126,7 +1093,7 @@ impl DiscoverableAuthenticationServerState { if cred.user_id == response.response.user_handle() { // Step 6 item 2. if cred.id == response.raw_id { - self.0.verify(true, rp_id, response, cred, options, None) + self.0.verify(rp_id, response, cred, options, None) } else { Err(AuthCeremonyErr::CredentialIdMismatch) } @@ -1218,7 +1185,7 @@ impl NonDiscoverableAuthenticationServerState { // Step 6 item 1. if c.id == cred.id { self.state - .verify(false, rp_id, response, cred, options, Some(c.ext)) + .verify(rp_id, response, cred, options, Some(c.ext)) } else { Err(AuthCeremonyErr::CredentialIdMismatch) } @@ -1283,7 +1250,6 @@ impl AuthenticationServerState { RsaKey: AsRef<[u8]>, >( self, - discoverable: bool, rp_id: &RpId, response: &'a Authentication<USER_LEN, DISCOVERABLE>, cred: &mut AuthenticatedCredential< @@ -1397,7 +1363,7 @@ impl AuthenticationServerState { match cred.static_state.extensions.cred_protect { CredentialProtectionPolicy::None | CredentialProtectionPolicy::UserVerificationOptional => Ok(()), CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList => { - if discoverable { + if DISCOVERABLE { Err(AuthCeremonyErr::DiscoverableCredProtectCredentialIdList) } else { Ok(()) @@ -1653,37 +1619,13 @@ mod tests { }; #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] use ed25519_dalek::{Signer as _, SigningKey}; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] use rsa::sha2::{Digest as _, Sha256}; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_BYTES: u8 = 0b010_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_TEXT: u8 = 0b011_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_MAP: u8 = 0b101_00000; #[test] #[cfg(all(feature = "custom", feature = "serializable_server_state"))] @@ -1703,179 +1645,31 @@ mod tests { }), }, }); - let mut opts = NonDiscoverableCredentialRequestOptions::second_factor(&rp_id, creds)?; + let mut opts = NonDiscoverableCredentialRequestOptions::second_factor(&rp_id, creds); opts.options.user_verification = UserVerificationRequirement::Required; opts.options.challenge = Challenge(0); opts.options.extensions = Extension { prf: None }; - let client_data_json = br#"{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); - // We over-allocate by 32 bytes. See [`AuthenticatorAssertion::new`] for more information. - let mut authenticator_data = Vec::with_capacity(164); - authenticator_data.extend_from_slice( - [ - // rpIdHash. - // This will be overwritten later. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - // flags. - // UP, UV, and ED (right-to-left). - 0b1000_0101, - // signCount. - // 0 as 32-bit big endian. - 0, - 0, - 0, - 0, - CBOR_MAP | 1, - CBOR_TEXT | 11, - b'h', - b'm', - b'a', - b'c', - b'-', - b's', - b'e', - b'c', - b'r', - b'e', - b't', - CBOR_BYTES | 24, - // Length is 80. - 80, - // Two HMAC outputs concatenated and encrypted. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ] - .as_slice(), - ); - authenticator_data[..32] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); - authenticator_data - .extend_from_slice(Sha256::digest(client_data_json.as_slice()).as_slice()); - authenticator_data.truncate(132); let server = opts.start_ceremony()?.0; + let enc_data = server.encode()?; + assert_eq!(enc_data.capacity(), 16 + 2 + 1 + 1 + 12 + 2 + 128 + 1); + assert_eq!(enc_data.len(), 16 + 2 + 1 + 1 + 12 + 2 + 16 + 2); assert!( server.is_eq(&NonDiscoverableAuthenticationServerState::decode( - server.encode()?.as_slice() + enc_data.as_slice() )?) ); let mut opts_2 = DiscoverableCredentialRequestOptions::passkey(&rp_id); opts_2.public_key.challenge = Challenge(0); opts_2.public_key.extensions = Extension { prf: None }; let server_2 = opts_2.start_ceremony()?.0; + let enc_data_2 = server_2 + .encode() + .map_err(AggErr::EncodeDiscoverableAuthenticationServerState)?; + assert_eq!(enc_data_2.capacity(), enc_data_2.len()); + assert_eq!(enc_data_2.len(), 16 + 1 + 1 + 12); assert!( server_2.is_eq(&DiscoverableAuthenticationServerState::decode( - server_2 - .encode() - .map_err(AggErr::EncodeDiscoverableAuthenticationServerState)? - .as_slice() + enc_data_2.as_slice() )?) ); Ok(()) @@ -2300,12 +2094,12 @@ mod tests { ExtensionReq::Allow, )); opts.cred.prf = PrfCredOptions::TrueNoHmac; - validate(opts)?; + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::AuthCeremony(auth_err) if matches!(auth_err, AuthCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::HmacSecretForNonHmacSecretCredential))), |_| false)); opts.response.hmac = HmacSecret::Two; assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::AuthCeremony(auth_err) if matches!(auth_err, AuthCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::InvalidHmacSecretValue(OneOrTwo::One, OneOrTwo::Two)))), |_| false)); opts.response.hmac = HmacSecret::One; opts.cred.prf = PrfCredOptions::FalseNoHmac; - assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::AuthCeremony(auth_err) if matches!(auth_err, AuthCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::PrfRequestedForPrfIncapableCred))), |_| false)); + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::AuthCeremony(auth_err) if matches!(auth_err, AuthCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::HmacSecretForNonHmacSecretCredential))), |_| false)); opts.response.user_verified = false; opts.request.prf_uv = PrfUvOptions::None(false); opts.cred.prf = PrfCredOptions::TrueHmacTrue; diff --git a/src/request/auth/error.rs b/src/request/auth/error.rs @@ -1,8 +1,8 @@ #[cfg(doc)] use super::{ - AllowedCredentials, CredentialSpecificExtension, DiscoverableCredentialRequestOptions, - Extension, NonDiscoverableCredentialRequestOptions, PublicKeyCredentialRequestOptions, - UserVerificationRequirement, + AllowedCredentials, CredentialMediationRequirement, CredentialSpecificExtension, + DiscoverableCredentialRequestOptions, Extension, NonDiscoverableCredentialRequestOptions, + PublicKeyCredentialRequestOptions, UserVerificationRequirement, }; use core::{ error::Error, @@ -10,19 +10,7 @@ use core::{ }; #[cfg(doc)] use std::time::{Instant, SystemTime}; -/// Error returned from [`NonDiscoverableCredentialRequestOptions::second_factor`] when -/// [`AllowedCredentials`] is empty. -#[derive(Clone, Copy, Debug)] -pub struct SecondFactorErr; -impl Display for SecondFactorErr { - #[inline] - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.write_str("allowed credentials was empty") - } -} -impl Error for SecondFactorErr {} -/// Error returned by [`DiscoverableCredentialRequestOptions::start_ceremony`] -/// and [`NonDiscoverableCredentialRequestOptions::start_ceremony`]. +/// Error returned by [`DiscoverableCredentialRequestOptions::start_ceremony`]. /// /// This happens when [`PublicKeyCredentialRequestOptions::timeout`] could not be added to [`Instant::now`] or /// [`SystemTime::now`]. @@ -35,3 +23,31 @@ impl Display for InvalidTimeout { } } impl Error for InvalidTimeout {} +/// Error returned by [`NonDiscoverableCredentialRequestOptions::start_ceremony`]. +#[derive(Clone, Copy, Debug)] +pub enum NonDiscoverableCredentialRequestOptionsErr { + /// Variant when [`NonDiscoverableCredentialRequestOptions::allow_credentials`] is + /// empty. + EmptyAllowedCredentials, + /// Variant when [`NonDiscoverableCredentialRequestOptions::mediation`] is + /// [`CredentialMediationRequirement::Conditional`]. + ConditionalMediationRequested, + /// Variant when [`PublicKeyCredentialRequestOptions::timeout`] could not be added to [`Instant::now`] or + /// [`SystemTime::now`]. + InvalidTimeout, +} +impl Display for NonDiscoverableCredentialRequestOptionsErr { + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(match *self { + Self::EmptyAllowedCredentials => { + "non-discoverable requests require a non-empty collection of allowed credentials" + } + Self::ConditionalMediationRequested => { + "non-discoverable requests are not allowed to use conditional mediation" + } + Self::InvalidTimeout => "the timeout could not be added to the current Instant", + }) + } +} +impl Error for NonDiscoverableCredentialRequestOptionsErr {} diff --git a/src/request/auth/ser.rs b/src/request/auth/ser.rs @@ -340,7 +340,7 @@ impl Serialize for DiscoverableAuthenticationClientState<'_, '_, '_> { /// }; /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap(); /// let json = serde_json::json!({ - /// "mediation":"optional", + /// "mediation":"required", /// "publicKey":{ /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", /// "timeout":300000, @@ -421,9 +421,9 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_, '_> { /// }); /// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// let mut options = NonDiscoverableCredentialRequestOptions::second_factor(&rp_id, creds)?; + /// let mut options = NonDiscoverableCredentialRequestOptions::second_factor(&rp_id, creds); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// let opts = options.options(); + /// let opts = &mut options.options; /// # #[cfg(not(all(feature = "bin", feature = "custom")))] /// # let mut opts = webauthn_rp::DiscoverableCredentialRequestOptions::passkey(&rp_id).public_key; /// opts.hints = Hint::SecurityKey; @@ -443,7 +443,7 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_, '_> { /// # #[cfg(all(feature = "bin", feature = "custom"))] /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap(); /// let json = serde_json::json!({ - /// "mediation":"optional", + /// "mediation":"required", /// "publicKey":{ /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", /// "timeout":300000, diff --git a/src/request/auth/ser_server_state.rs b/src/request/auth/ser_server_state.rs @@ -143,14 +143,21 @@ impl Encode for DiscoverableAuthenticationServerState { where Self: 'a; type Err = SystemTimeError; + #[expect( + clippy::arithmetic_side_effects, + reason = "comment justifies correctness" + )] #[inline] fn encode(&self) -> Result<Self::Output<'_>, Self::Err> { // Length of the anticipated most common output: // * 16 for `SentChallenge` // * 1 for `UserVerificationRequirement` - // * 1 or 3 for `ServerExtensionInfo` where we assume 1 is the most common + // * 1 or 2 for `ServerExtensionInfo` // * 12 for `SystemTime` - let mut buffer = Vec::with_capacity(16 + 1 + 1 + 12); + // Clearly cannot overflow. + let mut buffer = Vec::with_capacity( + 16 + 1 + 1 + usize::from(!matches!(self.0.extensions.prf, ServerPrfInfo::None)) + 12, + ); self.0.encode_into_buffer(&mut buffer).map(|()| buffer) } } @@ -187,12 +194,21 @@ impl Encode for NonDiscoverableAuthenticationServerState { fn encode(&self) -> Result<Self::Output<'_>, Self::Err> { // Length of the anticipated most common output: // * 16 for `SentChallenge` - // * 2 + large range for `[CredInfo]` where we assume [`CredInfo`] being - // empty is the most common + // * 2 + Σ(2 + len(id_i) + (1|2)) from i = 1 to i = `self.allow_credentials.len()` where i is the + // 1-based index of the `AllowedCredential` and len(id_i) is the number of bytes that makes up + // the ith `CredentialId`. Since `self.allow_credentials.len()` is inclusively between 1 and + // 65,535, the smallest this can be is 2 + 2 + 16 + 1 = 21; and the largest this can be is + // 2 + 65535(2 + 1023 + 2) = 67,304,447. We assume no credential-specific PRF is sent and the + // the average length of the `CredentialId`s is 128. // * 1 for `UserVerificationRequirement` - // * 1 or 3 for `ServerExtensionInfo` where we assume 1 is the most common + // * 1 or 2 for `ServerExtensionInfo` where we assume 1 is the most common // * 12 for `SystemTime` - let mut buffer = Vec::with_capacity(16 + 2 + 1 + 1 + 12); + // This is just an estimate; thus we rely on wrapping arithmetic. If the actual needed capacity is too big, + // then a `panic` will happen anyway once we serialize the entire payload. + let mut buffer = Vec::with_capacity( + (16usize + 2 + 1 + 1 + 12) + .wrapping_add(self.allow_credentials.len().wrapping_mul(2 + 128 + 1)), + ); self.state .encode_into_buffer(&mut buffer) .map_err(EncodeNonDiscoverableAuthenticationServerStateErr::SystemTime) @@ -291,7 +307,9 @@ impl Decode for NonDiscoverableAuthenticationServerState { Vec::decode_from_buffer(&mut input) .map_err(|_e| DecodeNonDiscoverableAuthenticationServerStateErr::Other) .and_then(|allow_credentials| { - if input.is_empty() { + if allow_credentials.is_empty() { + Err(DecodeNonDiscoverableAuthenticationServerStateErr::Other) + } else if input.is_empty() { Ok(Self { state, allow_credentials, diff --git a/src/request/register.rs b/src/request/register.rs @@ -13,16 +13,17 @@ use super::{ }, }, BackupReq, Ceremony, Challenge, CredentialMediationRequirement, ExtensionInfo, ExtensionReq, - Hint, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, SentChallenge, - THREE_HUNDRED_THOUSAND, TimedCeremony, UserVerificationRequirement, + FIVE_MINUTES, Hint, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, SentChallenge, + TimedCeremony, UserVerificationRequirement, register::error::{CreationOptionsErr, NicknameErr, UsernameErr}, }; #[cfg(doc)] use crate::{ request::{ - AsciiDomain, AsciiDomainStatic, DomainOrigin, Url, auth::PublicKeyCredentialRequestOptions, + AsciiDomain, AsciiDomainStatic, DomainOrigin, Url, + auth::{AuthenticationVerificationOptions, PublicKeyCredentialRequestOptions}, }, - response::{AuthTransports, AuthenticatorTransport, Backup, CollectedClientData}, + response::{AuthTransports, AuthenticatorTransport, Backup, CollectedClientData, Flag}, }; use alloc::borrow::Cow; use core::{ @@ -1314,6 +1315,13 @@ pub struct CredentialCreationOptions< const USER_LEN: usize, > { /// [`mediation`](https://www.w3.org/TR/credential-management-1/#dom-credentialcreationoptions-mediation). + /// + /// Note if this is [`CredentialMediationRequirement::Conditional`], one may want to ensure + /// [`AuthenticatorSelectionCriteria::user_verification`] is not [`UserVerificationRequirement::Required`] + /// since some authenticators cannot enforce user verification during registration ceremonies when conditional + /// mediation is used. Do note that in the event passkeys are to be created, one may want to set + /// [`AuthenticationVerificationOptions::update_uv`] to `true` since [`Flag::user_verified`] will + /// potentially be `false`. pub mediation: CredentialMediationRequirement, /// `public-key` [credential type](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry). pub public_key: PublicKeyCredentialCreationOptions< @@ -1598,7 +1606,7 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> /// have been previously registered. /// /// Creates a `PublicKeyCredentialCreationOptions` that requires the authenticator to create a client-side - /// discoverable credential enforcing any form of user verification. A five-minute timeout is set. + /// discoverable credential enforcing any form of user verification. [`Self::timeout`] is [`FIVE_MINUTES`]. /// [`Extension::cred_protect`] with [`CredProtect::UserVerificationRequired`] with `false` and /// [`ExtensionInfo::AllowEnforceValue`] is used. /// @@ -1638,7 +1646,7 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> user, challenge: Challenge::new(), pub_key_cred_params: CoseAlgorithmIdentifiers::default(), - timeout: THREE_HUNDRED_THOUSAND, + timeout: FIVE_MINUTES, exclude_credentials, authenticator_selection: AuthenticatorSelectionCriteria::passkey(), extensions: Extension { @@ -1688,8 +1696,8 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> /// have been previously registered. /// /// Creates a `PublicKeyCredentialCreationOptions` that prefers the authenticator to create a server-side - /// credential without requiring user verification. A five-minute timeout is set. [`Extension::cred_props`] - /// is [`ExtensionReq::Allow`]. [`Extension::cred_protect`] is + /// credential without requiring user verification. [`Self::timeout`] is [`FIVE_MINUTES`]. + /// [`Extension::cred_props`] is [`ExtensionReq::Allow`]. [`Extension::cred_protect`] is /// [`CredProtect::UserVerificationOptionalWithCredentialIdList`] with `false` and /// [`ExtensionInfo::AllowEnforceValue`]. /// @@ -2541,73 +2549,23 @@ mod tests { AuthenticatorAttachment, BackupReq, ExtensionErr, ExtensionReq, RegCeremonyErr, Registration, RegistrationVerificationOptions, UserVerificationRequirement, }; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] - use ed25519_dalek::{Signer as _, SigningKey}; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] use rsa::sha2::{Digest as _, Sha256}; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_UINT: u8 = 0b000_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_NEG: u8 = 0b001_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_BYTES: u8 = 0b010_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_TEXT: u8 = 0b011_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_MAP: u8 = 0b101_00000; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_SIMPLE: u8 = 0b111_00000; #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_FALSE: u8 = CBOR_SIMPLE | 20; - #[cfg(all( - feature = "custom", - any( - feature = "serializable_server_state", - not(any(feature = "bin", feature = "serde")) - ) - ))] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] const CBOR_TRUE: u8 = CBOR_SIMPLE | 21; #[test] #[cfg(all(feature = "custom", feature = "serializable_server_state"))] @@ -2643,319 +2601,13 @@ mod tests { ExtensionInfo::RequireEnforceValue, )), }; - let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); - // We over-allocate by 32 bytes. See [`AuthenticatorAttestation::new`] for more information. - let mut attestation_object = Vec::new(); - attestation_object.extend_from_slice( - [ - CBOR_MAP | 3, - CBOR_TEXT | 3, - b'f', - b'm', - b't', - CBOR_TEXT | 6, - b'p', - b'a', - b'c', - b'k', - b'e', - b'd', - CBOR_TEXT | 7, - b'a', - b't', - b't', - b'S', - b't', - b'm', - b't', - CBOR_MAP | 2, - CBOR_TEXT | 3, - b'a', - b'l', - b'g', - // COSE EdDSA. - CBOR_NEG | 7, - CBOR_TEXT | 3, - b's', - b'i', - b'g', - CBOR_BYTES | 24, - 64, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - CBOR_TEXT | 8, - b'a', - b'u', - b't', - b'h', - b'D', - b'a', - b't', - b'a', - CBOR_BYTES | 24, - // Length is 154. - 154, - // RP ID HASH. - // This will be overwritten later. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - // FLAGS. - // UP, UV, AT, and ED (right-to-left). - 0b1100_0101, - // COUNTER. - // 0 as 32-bit big endian. - 0, - 0, - 0, - 0, - // AAGUID. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - // L. - // CREDENTIAL ID length is 16 as 16-bit big endian. - 0, - 16, - // CREDENTIAL ID. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - CBOR_MAP | 4, - // COSE kty. - CBOR_UINT | 1, - // COSE OKP. - CBOR_UINT | 1, - // COSE alg. - CBOR_UINT | 3, - // COSE EdDSA. - CBOR_NEG | 7, - // COSE OKP crv. - CBOR_NEG, - // COSE Ed25519. - CBOR_UINT | 6, - // COSE OKP x. - CBOR_NEG | 1, - CBOR_BYTES | 24, - // Length is 32. - 32, - // Compressed-y coordinate. - // This will be overwritten later. - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - CBOR_MAP | 3, - CBOR_TEXT | 11, - b'c', - b'r', - b'e', - b'd', - b'P', - b'r', - b'o', - b't', - b'e', - b'c', - b't', - // userVerificationRequired. - CBOR_UINT | 3, - // CBOR text of length 11. - CBOR_TEXT | 11, - b'h', - b'm', - b'a', - b'c', - b'-', - b's', - b'e', - b'c', - b'r', - b'e', - b't', - CBOR_TRUE, - CBOR_TEXT | 12, - b'm', - b'i', - b'n', - b'P', - b'i', - b'n', - b'L', - b'e', - b'n', - b'g', - b't', - b'h', - CBOR_UINT | 16, - ] - .as_slice(), - ); - attestation_object - .extend_from_slice(Sha256::digest(client_data_json.as_slice()).as_slice()); - let sig_key = SigningKey::from_bytes(&[0; 32]); - let ver_key = sig_key.verifying_key(); - let pub_key = ver_key.as_bytes(); - attestation_object[107..139] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); - attestation_object[188..220].copy_from_slice(pub_key); - let sig = sig_key.sign(&attestation_object[107..]); - attestation_object[32..96].copy_from_slice(sig.to_bytes().as_slice()); - attestation_object.truncate(261); let server = opts.start_ceremony()?.0; - assert!( - server.is_eq(&RegistrationServerState::decode( - server - .encode() - .map_err(AggErr::EncodeRegistrationServerState)? - .as_slice() - )?) - ); + let enc_data = server + .encode() + .map_err(AggErr::EncodeRegistrationServerState)?; + assert_eq!(enc_data.capacity(), enc_data.len()); + assert_eq!(enc_data.len(), 1 + 16 + 1 + 4 + (1 + 3 + 3 + 2) + 12 + 1); + assert!(server.is_eq(&RegistrationServerState::decode(enc_data.as_slice())?)); Ok(()) } #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] diff --git a/src/request/register/ser.rs b/src/request/register/ser.rs @@ -206,7 +206,7 @@ impl Serialize for UserHandle<1> { { serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str( self.0.as_slice(), - [0; crate::base64url_nopad_len(1).unwrap()].as_mut_slice(), + [0; crate::base64url_nopad_len(1)].as_mut_slice(), )) } } @@ -224,7 +224,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).unwrap()].as_mut_slice())) + serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str(self.0.as_slice(), [0; crate::base64url_nopad_len($x)].as_mut_slice())) } } )* @@ -773,7 +773,7 @@ where /// # #[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!({ - /// "mediation":"optional", + /// "mediation":"required", /// "publicKey":{ /// "rp":{ /// "name":"example.com", @@ -970,19 +970,13 @@ where } #[expect( clippy::panic_in_result_fn, - clippy::unreachable, reason = "we want to crash when there is a bug" )] fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: Error, { - // Any value between `USER_HANDLE_MIN_LEN` and `USER_HANDLE_MAX_LEN` can be base64url encoded - // without fear since that range is just 1 to 64, and 4/3 of 64 is less than `usize::MAX`. - if crate::base64url_nopad_len(L).unwrap_or_else(|| { - unreachable!("there is a bug in webauthn_rp::base64url_nopad_len") - }) == v.len() - { + if crate::base64url_nopad_len(L) == v.len() { let mut data = [0; L]; BASE64URL_NOPAD .decode_mut(v.as_bytes(), data.as_mut_slice()) diff --git a/src/request/register/ser_server_state.rs b/src/request/register/ser_server_state.rs @@ -246,17 +246,41 @@ impl<const USER_LEN: usize> Encode for RegistrationServerState<USER_LEN> { where Self: 'a; type Err = SystemTimeError; + #[expect( + clippy::arithmetic_side_effects, + reason = "comment justifies correctness" + )] #[inline] fn encode(&self) -> Result<Self::Output<'_>, Self::Err> { // Length of the anticipated most common output: // * 1 for `CredentialMediationRequirement` // * 16 for `SentChallenge` // * 1 for `CoseAlgorithmIdentifiers` - // * 6 for `AuthenticatorSelectionCriteria` - // * 4–8 for `Extension` where we assume 4 is the most common + // * 4 for `AuthenticatorSelectionCriteria` + // * 4–10 for `Extension` // * 12 for `SystemTime` - // * Variable length for `U` where we assume a 16-byte `UserHandle` array is most common. - let mut buffer = Vec::with_capacity(1 + 16 + 1 + 6 + 4 + 12 + 16); + // * 1–64 for `UserHandle<USER_LEN>` + // Overflow cannot occur since `self.user_id` has max length of 64. + let mut buffer = Vec::with_capacity( + 1 + 16 + + 1 + + 4 + + (1 + usize::from(self.extensions.cred_props.is_some()) + + if matches!(self.extensions.cred_protect, CredProtect::None) { + 1 + } else { + 3 + } + + if self.extensions.min_pin_length.is_none() { + 1 + } else { + 3 + } + + 1 + + usize::from(!matches!(self.extensions.prf, ServerPrfInfo::None))) + + 12 + + self.user_id.0.len(), + ); self.mediation.encode_into_buffer(&mut buffer); self.challenge.encode_into_buffer(&mut buffer); self.pub_key_cred_params.encode_into_buffer(&mut buffer); diff --git a/src/request/ser.rs b/src/request/ser.rs @@ -14,21 +14,13 @@ impl Serialize for CredentialMediationRequirement { /// ``` /// # use webauthn_rp::request::CredentialMediationRequirement; /// assert_eq!( - /// serde_json::to_string(&CredentialMediationRequirement::Silent)?, - /// r#""silent""# - /// ); - /// assert_eq!( - /// serde_json::to_string(&CredentialMediationRequirement::Optional)?, - /// r#""optional""# + /// serde_json::to_string(&CredentialMediationRequirement::Required)?, + /// r#""required""# /// ); /// assert_eq!( /// serde_json::to_string(&CredentialMediationRequirement::Conditional)?, /// r#""conditional""# /// ); - /// assert_eq!( - /// serde_json::to_string(&CredentialMediationRequirement::Required)?, - /// r#""required""# - /// ); /// # Ok::<_, serde_json::Error>(()) /// ``` #[inline] @@ -37,8 +29,6 @@ impl Serialize for CredentialMediationRequirement { S: Serializer, { serializer.serialize_str(match *self { - Self::Silent => "silent", - Self::Optional => "optional", Self::Conditional => "conditional", Self::Required => "required", }) diff --git a/src/request/ser_server_state.rs b/src/request/ser_server_state.rs @@ -3,7 +3,7 @@ use super::{ CredentialMediationRequirement, ExtensionInfo, ExtensionReq, Hint, SentChallenge, UserVerificationRequirement, }; -use core::{convert::Infallible, time::Duration}; +use core::{convert::Infallible, num::NonZeroU32, time::Duration}; use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH}; /// [`ExtensionInfo::RequireEnforceValue`] tag. const EXT_INFO_REQUIRE_ENFORCE: u8 = 0; @@ -150,7 +150,10 @@ impl<'a> DecodeBuffer<'a> for SentChallenge { } } impl Encode for SentChallenge { - type Output<'a> = u128 where Self: 'a; + type Output<'a> + = u128 + where + Self: 'a; type Err = Infallible; #[inline] fn encode(&self) -> Result<Self::Output<'_>, Self::Err> { @@ -184,21 +187,15 @@ impl<'a> DecodeBuffer<'a> for UserVerificationRequirement { }) } } -/// [`CredentialMediationRequirement::Silent`] tag. -const CRED_MED_REQ_SILENT: u8 = 0; -/// [`CredentialMediationRequirement::Optional`] tag. -const CRED_MED_REQ_OPTIONAL: u8 = 1; -/// [`CredentialMediationRequirement::Conditional`] tag. -const CRED_MED_REQ_CONDITIONAL: u8 = 2; /// [`CredentialMediationRequirement::Required`] tag. -const CRED_MED_REQ_REQUIRED: u8 = 3; +const CRED_MED_REQ_REQUIRED: u8 = 0; +/// [`CredentialMediationRequirement::Conditional`] tag. +const CRED_MED_REQ_CONDITIONAL: u8 = 1; impl EncodeBuffer for CredentialMediationRequirement { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { match *self { - Self::Silent => CRED_MED_REQ_SILENT, - Self::Optional => CRED_MED_REQ_OPTIONAL, - Self::Conditional => CRED_MED_REQ_CONDITIONAL, Self::Required => CRED_MED_REQ_REQUIRED, + Self::Conditional => CRED_MED_REQ_CONDITIONAL, } .encode_into_buffer(buffer); } @@ -207,10 +204,8 @@ impl<'a> DecodeBuffer<'a> for CredentialMediationRequirement { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { u8::decode_from_buffer(data).and_then(|val| match val { - CRED_MED_REQ_SILENT => Ok(Self::Silent), - CRED_MED_REQ_OPTIONAL => Ok(Self::Optional), - CRED_MED_REQ_CONDITIONAL => Ok(Self::Conditional), CRED_MED_REQ_REQUIRED => Ok(Self::Required), + CRED_MED_REQ_CONDITIONAL => Ok(Self::Conditional), _ => Err(EncDecErr), }) } @@ -227,12 +222,35 @@ impl EncodeBufferFallible for SystemTime { impl<'a> DecodeBuffer<'a> for SystemTime { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { + /// Maximum duration possible for a timeout which is around 49.7 days. + #[expect( + clippy::as_conversions, + reason = "u32 as u64 is always OK, and we can't use u64::from in const contexts" + )] + const MAX_TIMEOUT: Duration = Duration::from_millis(NonZeroU32::MAX.get() as u64); u64::decode_from_buffer(data).and_then(|secs| { u32::decode_from_buffer(data).and_then(|nanos| { if nanos < 1_000_000_000 { UNIX_EPOCH .checked_add(Duration::new(secs, nanos)) .ok_or(EncDecErr) + .and_then(|exp| { + // The latest we could have started the ceremony is now which means the maximum + // expiry is now plus the maximum timeout. + if let Some(max_exp) = Self::now().checked_add(MAX_TIMEOUT) + && max_exp < exp + { + Err(EncDecErr) + } else { + // Note even when `SystemTime::now().checked_add(MAX_TIMEOUT)` is `None`, + // this is valid since the ceremony could have very recently been started. + // While this is highly unlikely seeing how `MAX_TIMEOUT` is less than 50 days; + // it is _technically_ possible if `SystemTime::now` is within 50 days + // of the maximum representable `SystemTime`. It is _far_ more likely that + // this branch is taken since `max_exp >= exp`. + Ok(exp) + } + }) } else { Err(EncDecErr) } diff --git a/src/response/auth/error.rs b/src/response/auth/error.rs @@ -161,8 +161,9 @@ pub enum ExtensionErr { UserNotVerifiedHmacSecret, /// [`AuthenticatorExtensionOutput::hmac_secret`] was sent from the client but was not supposed to be. ForbiddenHmacSecret, - /// [`Extension::prf`] was requested for a credential that does not support it. - PrfRequestedForPrfIncapableCred, + /// [`hmac-secret`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-hmac-secret-extension) + /// was received for a credential that does not support it. + HmacSecretForNonHmacSecretCredential, /// [`Extension::prf`] was requested for a PRF-capable credential that is based on the /// [`hmac-secret`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-hmac-secret-extension) /// extension, but the required response was not sent back. @@ -182,9 +183,9 @@ impl Display for ExtensionErr { Self::ForbiddenHmacSecret => { f.write_str("hmac-secret info was sent from the client, but it is not allowed") } - Self::PrfRequestedForPrfIncapableCred => { - f.write_str("prf extension was requested for a credential that is not PRF-capable") - } + Self::HmacSecretForNonHmacSecretCredential => f.write_str( + "hmac-secret info was sent from the client, but the credential does not support it", + ), Self::MissingHmacSecret => f.write_str("hmac-secret was not sent from the client"), Self::InvalidHmacSecretValue(sent, recv) => write!( f, 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::{ - AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, + self, AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, ClientExtensions, }, }, @@ -48,11 +48,11 @@ impl<'e> Deserialize<'e> for AuthData { where E: Error, { - crate::base64url_nopad_decode_len(v.len()) + ser::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 - // if usize::MAX / 4 < 32 => usize::MAX < 128 < u8::MAX; thus overflow is not + // if usize::MAX / 4 < 32 => usize::MAX < 128 < u16::MAX; thus overflow is not // possible. // We add 32 since the SHA-256 hash of `clientDataJSON` will be added to the // raw authenticator data by `AuthenticatorDataAssertion::new`. 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::{ - AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, + self, AuthenticationExtensionsPrfOutputsHelper, AuthenticationExtensionsPrfValues, Base64DecodedVal, ClientExtensions, PublicKeyCredential, }, }, @@ -765,7 +765,7 @@ impl<'e> Deserialize<'e> for AttObj { where E: Error, { - crate::base64url_nopad_decode_len(v.len()) + ser::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 diff --git a/src/response/ser.rs b/src/response/ser.rs @@ -277,22 +277,15 @@ impl<'de> Deserialize<'de> for CredentialId<Vec<u8>> { fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str("CredentialId") } - #[expect(clippy::unreachable, reason = "we want to crash when there is a bug")] fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: Error, { - // Any value between `super::CRED_ID_MIN_LEN` and `super::CRED_ID_MIN_LEN` can be base64url encoded - // without fear since that range is just 16 to 1023, and - // 4/3 of 1023 is less than `usize::MAX`. - if (crate::base64url_nopad_len(super::CRED_ID_MIN_LEN).unwrap_or_else(|| { - unreachable!("there is a bug in webauthn_rp::base64url_nopad_len") - }) - ..=crate::base64url_nopad_len(super::CRED_ID_MAX_LEN).unwrap_or_else(|| { - unreachable!("there is a bug in webauthn_rp::base64url_nopad_len") - })) - .contains(&v.len()) - { + /// Minimum possible encoded length of a `CredentialId`. + const MIN_LEN: usize = crate::base64url_nopad_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); + if (MIN_LEN..=MAX_LEN).contains(&v.len()) { BASE64URL_NOPAD .decode(v.as_bytes()) .map_err(E::custom) @@ -1205,3 +1198,66 @@ 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)) + } +}