commit 554b521f867d6cc1b00456b90dd59b7d92607fed
parent 78b64db0e7571cf9fb3f8db2ecc500447f6a50bc
Author: Zack Newman <zack@philomathiclife.com>
Date: Fri, 18 Jul 2025 17:53:35 -0600
small improvements
Diffstat:
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))
+ }
+}