webauthn_rp

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

commit 78b64db0e7571cf9fb3f8db2ecc500447f6a50bc
parent 1d95b0f6f7b1a68e29404a5dcd679b573d460618
Author: Zack Newman <zack@philomathiclife.com>
Date:   Tue,  8 Jul 2025 13:26:39 -0600

static ascii domain

Diffstat:
MCargo.toml | 17++++++++++++-----
MREADME.md | 77++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/hash/hash_set.rs | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/lib.rs | 73++++++++++++++++++++++++++++---------------------------------------------
Msrc/request.rs | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/request/auth.rs | 48+++++++++++++++++++++++++++++++++++++++---------
Msrc/request/error.rs | 2+-
Msrc/request/register.rs | 32++++++++++++++++++++++++++------
Msrc/request/ser.rs | 9++-------
Msrc/response.rs | 50+++++++++++++++++++++++++++++++++++++++-----------
Msrc/response/register.rs | 54++++++++++++++++++++++++++----------------------------
Msrc/response/ser.rs | 6+-----
12 files changed, 594 insertions(+), 244 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -9,25 +9,31 @@ license = "MIT OR Apache-2.0" name = "webauthn_rp" readme = "README.md" repository = "https://git.philomathiclife.com/repos/webauthn_rp/" -rust-version = "1.87.0" +rust-version = "1.88.0" version = "0.4.0" [lints.rust] ambiguous_negative_literals = { level = "deny", priority = -1 } closure_returning_async_block = { level = "deny", priority = -1 } +deprecated_safe = { level = "deny", priority = -1 } deref_into_dyn_supertrait = { level = "deny", priority = -1 } ffi_unwind_calls = { level = "deny", priority = -1 } future_incompatible = { level = "deny", priority = -1 } +#fuzzy_provenance_casts = { level = "deny", priority = -1 } impl_trait_redundant_captures = { level = "deny", priority = -1 } -keyword-idents = { level = "deny", priority = -1 } +keyword_idents = { level = "deny", priority = -1 } let_underscore = { level = "deny", priority = -1 } linker_messages = { level = "deny", priority = -1 } +#lossy_provenance_casts = { level = "deny", priority = -1 } macro_use_extern_crate = { level = "deny", priority = -1 } meta_variable_misuse = { level = "deny", priority = -1 } missing_copy_implementations = { level = "deny", priority = -1 } missing_debug_implementations = { level = "deny", priority = -1 } missing_docs = { level = "deny", priority = -1 } +#multiple_supertrait_upcastable = { level = "deny", priority = -1 } +#must_not_suspend = { level = "deny", priority = -1 } non_ascii_idents = { level = "deny", priority = -1 } +#non_exhaustive_omitted_patterns = { level = "deny", priority = -1 } nonstandard_style = { level = "deny", priority = -1 } redundant_imports = { level = "deny", priority = -1 } redundant_lifetimes = { level = "deny", priority = -1 } @@ -36,12 +42,13 @@ rust_2018_compatibility = { level = "deny", priority = -1 } rust_2018_idioms = { level = "deny", priority = -1 } rust_2021_compatibility = { level = "deny", priority = -1 } rust_2024_compatibility = { level = "deny", priority = -1 } -single-use-lifetimes = { level = "deny", priority = -1 } +single_use_lifetimes = { level = "deny", priority = -1 } +#supertrait_item_shadowing_definition = { level = "deny", priority = -1 } trivial_casts = { level = "deny", priority = -1 } trivial_numeric_casts = { level = "deny", priority = -1 } unit_bindings = { level = "deny", priority = -1 } -unknown_lints = { level = "deny", priority = -1 } unnameable_types = { level = "deny", priority = -1 } +#unqualified_local_imports = { level = "deny", priority = -1 } unreachable_pub = { level = "deny", priority = -1 } unsafe_code = { level = "deny", priority = -1 } unstable_features = { level = "deny", priority = -1 } @@ -74,8 +81,8 @@ implicit_return = "allow" min_ident_chars = "allow" missing_trait_methods = "allow" module_name_repetitions = "allow" -pub_with_shorthand = "allow" pub_use = "allow" +pub_with_shorthand = "allow" question_mark_used = "allow" ref_patterns = "allow" return_and_then = "allow" diff --git a/README.md b/README.md @@ -20,11 +20,11 @@ having said that, there are pre-defined serialization formats for "common" deplo use core::convert; use webauthn_rp::{ AuthenticatedCredential64, DiscoverableAuthentication64, DiscoverableAuthenticationServerState, - DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions64, RegisteredCredential64, + DiscoverableCredentialRequestOptions, CredentialCreationOptions64, RegisteredCredential64, Registration, RegistrationServerState64, hash::hash_set::FixedCapHashSet, request::{ - AsciiDomain, PublicKeyCredentialDescriptor, RpId, + AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId, auth::AuthenticationVerificationOptions, register::{ Nickname, PublicKeyCredentialUserEntity64, RegistrationVerificationOptions, @@ -34,13 +34,17 @@ use webauthn_rp::{ response::{ CredentialId, auth::error::AuthCeremonyErr, - register::{CompressedPubKey, DynamicState, error::RegCeremonyErr}, + register::{CompressedPubKeyOwned, DynamicState, error::RegCeremonyErr}, }, }; use serde::de::{Deserialize, Deserializer}; use serde_json::Error as JsonErr; /// The RP ID our application uses. -const RP_ID: &str = "example.com"; +const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +/// The registration verification options. +const REG_OPTS: &RegistrationVerificationOptions::<'static, 'static, &'static str, &'static str> = &RegistrationVerificationOptions::new(); +/// The authentication verification options. +const AUTH_OPTS: &AuthenticationVerificationOptions::<'static, 'static, &'static str, &'static str> = &AuthenticationVerificationOptions::new(); /// Error we return in our application when a function fails. enum AppErr { /// WebAuthn registration ceremony failed. @@ -101,14 +105,10 @@ impl<'de: 'a + 'b, 'a, 'b> Deserialize<'de> for AccountReg<'a, 'b> { fn start_account_creation( reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, ) -> Result<Vec<u8>, AppErr> { - let rp_id = RpId::Domain( - AsciiDomain::try_from(RP_ID.to_owned()) - .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), - ); let user_id = UserHandle64::new(); let (server, client) = - PublicKeyCredentialCreationOptions64::first_passkey_with_blank_user_info( - &rp_id, &user_id, + CredentialCreationOptions64::first_passkey_with_blank_user_info( + RP_ID, &user_id, ) .start_ceremony() .unwrap_or_else(|_e| { @@ -132,9 +132,9 @@ fn start_account_creation( /// authenticator. fn finish_account_creation( reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, - client_data: Vec<u8>, + client_data: &[u8], ) -> Result<(), AppErr> { - let account = serde_json::from_slice::<AccountReg<'_, '_>>(client_data.as_slice())?; + let account = serde_json::from_slice::<AccountReg<'_, '_>>(client_data)?; insert_account( &account, reg_ceremonies @@ -142,12 +142,9 @@ fn finish_account_creation( .take(&account.registration.challenge_relaxed()?) .ok_or(AppErr::MissingWebAuthnCeremony)? .verify( - &RpId::Domain( - AsciiDomain::try_from(RP_ID.to_owned()) - .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), - ), + RP_ID, &account.registration, - &RegistrationVerificationOptions::<&str, &str>::default(), + REG_OPTS, )?, ) } @@ -161,12 +158,8 @@ fn start_cred_registration( user_id: &UserHandle64, reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, ) -> Result<Vec<u8>, AppErr> { - let rp_id = RpId::Domain( - AsciiDomain::try_from(RP_ID.to_owned()) - .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), - ); - let (entity, creds) = select_user_info(user_id)?.ok_or_else(|| AppErr::NoAccount)?; - let (server, client) = PublicKeyCredentialCreationOptions64::passkey(&rp_id, entity, creds) + let (entity, creds) = select_user_info(user_id)?.ok_or(AppErr::NoAccount)?; + let (server, client) = CredentialCreationOptions64::passkey(RP_ID, entity, creds) .start_ceremony() .unwrap_or_else(|_e| { unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") @@ -189,22 +182,19 @@ fn start_cred_registration( /// authenticator. fn finish_cred_registration( reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, - client_data: Vec<u8>, + client_data: &[u8], ) -> Result<(), AppErr> { // `Registration::from_json_custom` is available iff `serde_relaxed` is enabled. - let registration = Registration::from_json_custom(client_data.as_slice())?; + let registration = Registration::from_json_custom(client_data)?; insert_credential( reg_ceremonies // `Registration::challenge_relaxed` is available iff `serde_relaxed` is enabled. .take(&registration.challenge_relaxed()?) .ok_or(AppErr::MissingWebAuthnCeremony)? .verify( - &RpId::Domain( - AsciiDomain::try_from(RP_ID.to_owned()) - .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), - ), + RP_ID, &registration, - &RegistrationVerificationOptions::<&str, &str>::default(), + REG_OPTS, )?, ) } @@ -212,11 +202,7 @@ fn finish_cred_registration( fn start_auth( auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, ) -> Result<Vec<u8>, AppErr> { - let rp_id = RpId::Domain( - AsciiDomain::try_from(RP_ID.to_owned()) - .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), - ); - let (server, client) = DiscoverableCredentialRequestOptions::passkey(&rp_id) + let (server, client) = DiscoverableCredentialRequestOptions::passkey(RP_ID) .start_ceremony() .unwrap_or_else(|_e| { unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") @@ -233,28 +219,25 @@ fn start_auth( /// Finishes the passkey authentication ceremony. fn finish_auth( auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, - client_data: Vec<u8>, + client_data: &[u8], ) -> Result<(), AppErr> { // `DiscoverableAuthentication64::from_json_custom` is available iff `serde_relaxed` is enabled. let authentication = - DiscoverableAuthentication64::from_json_custom(client_data.as_slice())?; + DiscoverableAuthentication64::from_json_custom(client_data)?; let mut cred = select_credential( authentication.raw_id(), authentication.response().user_handle(), )? - .ok_or_else(|| AppErr::NoCredential)?; + .ok_or(AppErr::NoCredential)?; if auth_ceremonies // `DiscoverableAuthentication64::challenge_relaxed` is available iff `serde_relaxed` is enabled. .take(&authentication.challenge_relaxed()?) .ok_or(AppErr::MissingWebAuthnCeremony)? .verify( - &RpId::Domain( - AsciiDomain::try_from(RP_ID.to_owned()) - .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), - ), + RP_ID, &authentication, &mut cred, - &AuthenticationVerificationOptions::<&str, &str>::default(), + AUTH_OPTS, )? { update_credential(cred.id(), cred.dynamic_state()) @@ -279,11 +262,11 @@ fn insert_account( /// # Errors /// /// Errors iff fetching the data errors. -fn select_user_info<'a>( - user_id: &'a UserHandle64, +fn select_user_info( + user_id: &UserHandle64, ) -> Result< Option<( - PublicKeyCredentialUserEntity64<'static, 'static, 'a>, + PublicKeyCredentialUserEntity64<'static, 'static, '_>, Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, )>, AppErr, @@ -315,7 +298,7 @@ fn select_credential<'cred, 'user>( AuthenticatedCredential64< 'cred, 'user, - CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>, + CompressedPubKeyOwned, >, >, AppErr, diff --git a/src/hash/hash_set.rs b/src/hash/hash_set.rs @@ -10,7 +10,7 @@ use hashbrown::{ use std::time::Instant; #[cfg(feature = "serializable_server_state")] use std::time::SystemTime; -/// `newtype` around [`HashSet`] that maximum [`HashSet::capacity`]. +/// `newtype` around [`HashSet`] that has maximum [`HashSet::capacity`]. /// /// This is useful in situations when the underlying values are expected to be removed, and one wants to ensure the /// set does not grow unbounded. When `T` is a [`TimedCeremony`], helper methods (e.g., @@ -254,3 +254,83 @@ impl<T, S> From<HashSet<T, S>> for FixedCapHashSet<T, S> { Self(value) } } +#[cfg(test)] +mod tests { + use super::{Equivalent, FixedCapHashSet, TimedCeremony}; + use core::hash::{Hash, Hasher}; + #[cfg(not(feature = "serializable_server_state"))] + use std::time::Instant; + #[cfg(feature = "serializable_server_state")] + use std::time::SystemTime; + #[derive(Clone, Copy)] + struct Ceremony { + id: usize, + #[cfg(not(feature = "serializable_server_state"))] + exp: Instant, + #[cfg(feature = "serializable_server_state")] + exp: SystemTime, + } + impl Default for Ceremony { + fn default() -> Self { + Self { + id: 0, + #[cfg(not(feature = "serializable_server_state"))] + exp: Instant::now(), + #[cfg(feature = "serializable_server_state")] + exp: SystemTime::now(), + } + } + } + impl PartialEq for Ceremony { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } + } + impl Eq for Ceremony {} + impl Hash for Ceremony { + fn hash<H: Hasher>(&self, state: &mut H) { + self.id.hash(state); + } + } + impl TimedCeremony for Ceremony { + #[cfg(not(feature = "serializable_server_state"))] + fn expiration(&self) -> Instant { + self.exp + } + #[cfg(feature = "serializable_server_state")] + fn expiration(&self) -> SystemTime { + self.exp + } + } + impl Equivalent<Ceremony> for usize { + fn equivalent(&self, key: &Ceremony) -> bool { + *self == key.id + } + } + #[test] + fn hash_set_insert_removed() { + let mut set = FixedCapHashSet::new(8); + let cap = set.as_ref().capacity(); + let mut cer = Ceremony::default(); + for i in 0..cap { + assert_eq!(set.as_ref().capacity(), cap); + cer.id = i; + assert_eq!(set.insert(cer), Some(true)); + } + assert_eq!(set.as_ref().capacity(), cap); + assert_eq!(set.as_ref().len(), cap); + for i in 0..cap { + assert!(set.as_ref().contains(&i)); + } + cer.id = cap; + assert_eq!(set.insert_remove_expired(cer), Some(true)); + assert_eq!(set.as_ref().capacity(), cap); + assert_eq!(set.as_ref().len(), cap); + let mut counter = 0; + for i in 0..cap { + counter += usize::from(set.as_ref().contains(&i)); + } + assert_eq!(counter, cap - 1); + assert!(set.as_ref().contains(&cap)); + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -24,7 +24,7 @@ //! Registration, RegistrationServerState64, //! hash::hash_set::FixedCapHashSet, //! request::{ -//! AsciiDomain, PublicKeyCredentialDescriptor, RpId, +//! AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId, //! auth::AuthenticationVerificationOptions, //! register::{ //! Nickname, PublicKeyCredentialUserEntity64, RegistrationVerificationOptions, @@ -34,7 +34,7 @@ //! response::{ //! CredentialId, //! auth::error::AuthCeremonyErr, -//! register::{CompressedPubKey, DynamicState, error::RegCeremonyErr}, +//! register::{CompressedPubKeyOwned, DynamicState, error::RegCeremonyErr}, //! }, //! }; //! # #[cfg(feature = "serde")] @@ -42,7 +42,11 @@ //! # #[cfg(feature = "serde_relaxed")] //! use serde_json::Error as JsonErr; //! /// The RP ID our application uses. -//! const RP_ID: &str = "example.com"; +//! const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); +//! /// The registration verification options. +//! const REG_OPTS: &RegistrationVerificationOptions::<'static, 'static, &'static str, &'static str> = &RegistrationVerificationOptions::new(); +//! /// The authentication verification options. +//! const AUTH_OPTS: &AuthenticationVerificationOptions::<'static, 'static, &'static str, &'static str> = &AuthenticationVerificationOptions::new(); //! /// Error we return in our application when a function fails. //! enum AppErr { //! /// WebAuthn registration ceremony failed. @@ -108,14 +112,10 @@ //! fn start_account_creation( //! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, //! ) -> Result<Vec<u8>, AppErr> { -//! let rp_id = RpId::Domain( -//! AsciiDomain::try_from(RP_ID.to_owned()) -//! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), -//! ); //! let user_id = UserHandle64::new(); //! let (server, client) = //! CredentialCreationOptions64::first_passkey_with_blank_user_info( -//! &rp_id, &user_id, +//! RP_ID, &user_id, //! ) //! .start_ceremony() //! .unwrap_or_else(|_e| { @@ -140,9 +140,9 @@ //! # #[cfg(feature = "serde_relaxed")] //! fn finish_account_creation( //! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, -//! client_data: Vec<u8>, +//! client_data: &[u8], //! ) -> Result<(), AppErr> { -//! let account = serde_json::from_slice::<AccountReg<'_, '_>>(client_data.as_slice())?; +//! let account = serde_json::from_slice::<AccountReg<'_, '_>>(client_data)?; //! insert_account( //! &account, //! reg_ceremonies @@ -150,12 +150,9 @@ //! .take(&account.registration.challenge_relaxed()?) //! .ok_or(AppErr::MissingWebAuthnCeremony)? //! .verify( -//! &RpId::Domain( -//! AsciiDomain::try_from(RP_ID.to_owned()) -//! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), -//! ), +//! RP_ID, //! &account.registration, -//! &RegistrationVerificationOptions::<&str, &str>::default(), +//! REG_OPTS, //! )?, //! ) //! } @@ -170,12 +167,8 @@ //! user_id: &UserHandle64, //! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, //! ) -> Result<Vec<u8>, AppErr> { -//! let rp_id = RpId::Domain( -//! AsciiDomain::try_from(RP_ID.to_owned()) -//! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), -//! ); -//! let (entity, creds) = select_user_info(user_id)?.ok_or_else(|| AppErr::NoAccount)?; -//! let (server, client) = CredentialCreationOptions64::passkey(&rp_id, entity, creds) +//! let (entity, creds) = select_user_info(user_id)?.ok_or(AppErr::NoAccount)?; +//! let (server, client) = CredentialCreationOptions64::passkey(RP_ID, entity, creds) //! .start_ceremony() //! .unwrap_or_else(|_e| { //! unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") @@ -199,22 +192,19 @@ //! # #[cfg(feature = "serde_relaxed")] //! fn finish_cred_registration( //! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, -//! client_data: Vec<u8>, +//! client_data: &[u8], //! ) -> Result<(), AppErr> { //! // `Registration::from_json_custom` is available iff `serde_relaxed` is enabled. -//! let registration = Registration::from_json_custom(client_data.as_slice())?; +//! let registration = Registration::from_json_custom(client_data)?; //! insert_credential( //! reg_ceremonies //! // `Registration::challenge_relaxed` is available iff `serde_relaxed` is enabled. //! .take(&registration.challenge_relaxed()?) //! .ok_or(AppErr::MissingWebAuthnCeremony)? //! .verify( -//! &RpId::Domain( -//! AsciiDomain::try_from(RP_ID.to_owned()) -//! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), -//! ), +//! RP_ID, //! &registration, -//! &RegistrationVerificationOptions::<&str, &str>::default(), +//! REG_OPTS, //! )?, //! ) //! } @@ -223,11 +213,7 @@ //! fn start_auth( //! auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, //! ) -> Result<Vec<u8>, AppErr> { -//! let rp_id = RpId::Domain( -//! AsciiDomain::try_from(RP_ID.to_owned()) -//! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), -//! ); -//! let (server, client) = DiscoverableCredentialRequestOptions::passkey(&rp_id) +//! let (server, client) = DiscoverableCredentialRequestOptions::passkey(RP_ID) //! .start_ceremony() //! .unwrap_or_else(|_e| { //! unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") @@ -245,28 +231,25 @@ //! # #[cfg(feature = "serde_relaxed")] //! fn finish_auth( //! auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, -//! client_data: Vec<u8>, +//! client_data: &[u8], //! ) -> Result<(), AppErr> { //! // `DiscoverableAuthentication64::from_json_custom` is available iff `serde_relaxed` is enabled. //! let authentication = -//! DiscoverableAuthentication64::from_json_custom(client_data.as_slice())?; +//! DiscoverableAuthentication64::from_json_custom(client_data)?; //! let mut cred = select_credential( //! authentication.raw_id(), //! authentication.response().user_handle(), //! )? -//! .ok_or_else(|| AppErr::NoCredential)?; +//! .ok_or(AppErr::NoCredential)?; //! if auth_ceremonies //! // `DiscoverableAuthentication64::challenge_relaxed` is available iff `serde_relaxed` is enabled. //! .take(&authentication.challenge_relaxed()?) //! .ok_or(AppErr::MissingWebAuthnCeremony)? //! .verify( -//! &RpId::Domain( -//! AsciiDomain::try_from(RP_ID.to_owned()) -//! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), -//! ), +//! RP_ID, //! &authentication, //! &mut cred, -//! &AuthenticationVerificationOptions::<&str, &str>::default(), +//! AUTH_OPTS, //! )? //! { //! update_credential(cred.id(), cred.dynamic_state()) @@ -292,11 +275,11 @@ //! /// # Errors //! /// //! /// Errors iff fetching the data errors. -//! fn select_user_info<'a>( -//! user_id: &'a UserHandle64, +//! fn select_user_info( +//! user_id: &UserHandle64, //! ) -> Result< //! Option<( -//! PublicKeyCredentialUserEntity64<'static, 'static, 'a>, +//! PublicKeyCredentialUserEntity64<'static, 'static, '_>, //! Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, //! )>, //! AppErr, @@ -330,7 +313,7 @@ //! AuthenticatedCredential64< //! 'cred, //! 'user, -//! CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>, +//! CompressedPubKeyOwned, //! >, //! >, //! AppErr, diff --git a/src/request.rs b/src/request.rs @@ -46,14 +46,14 @@ use url::Url as Uri; /// # request::{ /// # auth::{AllowedCredentials, DiscoverableCredentialRequestOptions, NonDiscoverableCredentialRequestOptions}, /// # register::UserHandle64, -/// # AsciiDomain, Credentials, PublicKeyCredentialDescriptor, RpId, +/// # AsciiDomainStatic, Credentials, PublicKeyCredentialDescriptor, RpId, /// # }, /// # response::{AuthTransports, CredentialId, CRED_ID_MIN_LEN}, /// # AggErr, /// # }; +/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); /// let mut ceremonies = FixedCapHashSet::new(128); -/// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); -/// let (server, client) = DiscoverableCredentialRequestOptions::passkey(&rp_id).start_ceremony()?; +/// let (server, client) = DiscoverableCredentialRequestOptions::passkey(RP_ID).start_ceremony()?; /// assert!( /// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) /// ); @@ -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) @@ -110,14 +110,14 @@ pub mod error; /// # register::{ /// # CredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle, USER_HANDLE_MAX_LEN, UserHandle64, /// # }, -/// # AsciiDomain, PublicKeyCredentialDescriptor, RpId +/// # AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId /// # }, /// # response::{AuthTransports, CredentialId, CRED_ID_MIN_LEN}, /// # AggErr, /// # }; +/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); /// # #[cfg(feature = "custom")] /// let mut ceremonies = FixedCapHashSet::new(128); -/// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// # #[cfg(feature = "custom")] /// let user_handle = get_user_handle(); /// # #[cfg(feature = "custom")] @@ -125,7 +125,7 @@ pub mod error; /// # #[cfg(feature = "custom")] /// let creds = get_registered_credentials(&user_handle)?; /// # #[cfg(feature = "custom")] -/// let (server, client) = CredentialCreationOptions::passkey(&rp_id, user.clone(), creds) +/// let (server, client) = CredentialCreationOptions::passkey(RP_ID, user.clone(), creds) /// .start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( @@ -137,7 +137,7 @@ pub mod error; /// let creds_2 = get_registered_credentials(&user_handle)?; /// # #[cfg(feature = "custom")] /// let (server_2, client_2) = -/// CredentialCreationOptions::second_factor(&rp_id, user, creds_2).start_ceremony()?; +/// CredentialCreationOptions::second_factor(RP_ID, user, creds_2).start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( /// ceremonies.insert_remove_all_expired(server_2).map_or(false, convert::identity) @@ -230,6 +230,22 @@ impl Challenge { pub const fn as_data(&self) -> u128 { self.0 } + /// Returns the contained `u128` as a little-endian `array` consuming `self`. + #[inline] + #[must_use] + pub const fn into_array(self) -> [u8; 16] { + self.as_array() + } + /// Returns the contained `u128` as a little-endian `array`. + #[expect( + clippy::little_endian_bytes, + reason = "Challenge and SentChallenge need to be compatible, and we need to ensure the data is sent and received in the same order" + )] + #[inline] + #[must_use] + pub const fn as_array(&self) -> [u8; 16] { + self.0.to_le_bytes() + } } impl Default for Challenge { /// Same as [`Self::new`]. @@ -250,6 +266,18 @@ impl From<&Challenge> for u128 { value.0 } } +impl From<Challenge> for [u8; 16] { + #[inline] + fn from(value: Challenge) -> Self { + value.into_array() + } +} +impl From<&Challenge> for [u8; 16] { + #[inline] + fn from(value: &Challenge) -> Self { + value.as_array() + } +} /// A [domain](https://url.spec.whatwg.org/#concept-domain) in representation format consisting of only and any /// ASCII. /// @@ -257,6 +285,8 @@ impl From<&Challenge> for u128 { /// label must have length inclusively between 1 and 63, and the total length of the domain must be at most 253 /// when a trailing `'.'` does not exist; otherwise the max length is 254. The root domain (i.e., `'.'`) is not /// allowed. +/// +/// Note if the domain is a `&'static str`, then use [`AsciiDomainStatic`] instead. #[derive(Clone, Debug, Eq, PartialEq)] pub struct AsciiDomain(String); impl AsciiDomain { @@ -265,8 +295,11 @@ impl AsciiDomain { /// # Examples /// /// ``` - /// # use webauthn_rp::request::{error::AsciiDomainErr, AsciiDomain}; + /// # use webauthn_rp::request::{AsciiDomain, error::AsciiDomainErr}; /// let mut dom = AsciiDomain::try_from("example.com.".to_owned())?; + /// assert_eq!(dom.as_ref(), "example.com."); + /// dom.remove_trailing_dot(); + /// assert_eq!(dom.as_ref(), "example.com"); /// dom.remove_trailing_dot(); /// assert_eq!(dom.as_ref(), "example.com"); /// # Ok::<_, AsciiDomainErr>(()) @@ -278,7 +311,7 @@ impl AsciiDomain { .0 .as_bytes() .last() - .unwrap_or_else(|| unreachable!("there is a bug in AsciiDomain::try_from")) + .unwrap_or_else(|| unreachable!("there is a bug in AsciiDomain::from_slice")) == b'.' { _ = self.0.pop(); @@ -315,7 +348,7 @@ impl PartialEq<AsciiDomain> for &AsciiDomain { **self == *other } } -impl TryFrom<String> for AsciiDomain { +impl TryFrom<Vec<u8>> for AsciiDomain { type Error = AsciiDomainErr; /// Verifies `value` is an ASCII domain in representation format converting any uppercase ASCII into /// lowercase. @@ -327,67 +360,71 @@ impl TryFrom<String> for AsciiDomain { /// (e.g., [`AsciiDomain::remove_trailing_dot`]). Because this allows any ASCII, one may want to ensure `value` /// is not an IP address. /// + /// # Errors + /// + /// Errors iff `value` is not a valid ASCII domain. + /// /// # Examples /// /// ``` /// # use webauthn_rp::request::{error::AsciiDomainErr, AsciiDomain}; /// // Root `'.'` is not removed if it exists. - /// assert_ne!("example.com", AsciiDomain::try_from("example.com.".to_owned())?.as_ref()); + /// assert_ne!("example.com", AsciiDomain::try_from(b"example.com.".to_vec())?.as_ref()); /// // Root domain (i.e., `'.'`) is not allowed. - /// assert!(AsciiDomain::try_from(".".to_owned()).is_err()); + /// assert!(AsciiDomain::try_from(vec![b'.']).is_err()); /// // Uppercase is transformed into lowercase. - /// assert_eq!("example.com", AsciiDomain::try_from("ExAmPle.CoM".to_owned())?.as_ref()); + /// assert_eq!("example.com", AsciiDomain::try_from(b"ExAmPle.CoM".to_vec())?.as_ref()); /// // The only ASCII character not allowed in a domain label is `'.'` as it is used exclusively to delimit /// // labels. - /// assert_eq!("\x00", AsciiDomain::try_from("\x00".to_owned())?.as_ref()); + /// assert_eq!("\x00", AsciiDomain::try_from(b"\x00".to_vec())?.as_ref()); /// // Empty labels are not allowed. - /// assert!(AsciiDomain::try_from("example..com".to_owned()).is_err()); + /// assert!(AsciiDomain::try_from(b"example..com".to_vec()).is_err()); /// // Labels cannot have length greater than 63. /// let mut long_label = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(); /// assert_eq!(long_label.len(), 64); - /// assert!(AsciiDomain::try_from(long_label.clone()).is_err()); + /// assert!(AsciiDomain::try_from(long_label.clone().into_bytes()).is_err()); /// long_label.pop(); - /// assert_eq!(long_label, AsciiDomain::try_from(long_label.clone())?.as_ref()); + /// assert_eq!(long_label, AsciiDomain::try_from(long_label.clone().into_bytes())?.as_ref()); /// // The maximum length of a domain is 254 if a trailing `'.'` exists; otherwise the max length is 253. /// let mut long_domain = format!("{long_label}.{long_label}.{long_label}.{long_label}"); /// long_domain.pop(); /// long_domain.push('.'); /// assert_eq!(long_domain.len(), 255); - /// assert!(AsciiDomain::try_from(long_domain.clone()).is_err()); + /// assert!(AsciiDomain::try_from(long_domain.clone().into_bytes()).is_err()); /// long_domain.pop(); /// long_domain.pop(); /// long_domain.push('.'); /// assert_eq!(long_domain.len(), 254); - /// assert_eq!(long_domain, AsciiDomain::try_from(long_domain.clone())?.as_ref()); + /// assert_eq!(long_domain, AsciiDomain::try_from(long_domain.clone().into_bytes())?.as_ref()); /// long_domain.pop(); /// long_domain.push('a'); /// assert_eq!(long_domain.len(), 254); - /// assert!(AsciiDomain::try_from(long_domain.clone()).is_err()); + /// assert!(AsciiDomain::try_from(long_domain.clone().into_bytes()).is_err()); /// long_domain.pop(); /// assert_eq!(long_domain.len(), 253); - /// assert_eq!(long_domain, AsciiDomain::try_from(long_domain.clone())?.as_ref()); + /// assert_eq!(long_domain, AsciiDomain::try_from(long_domain.clone().into_bytes())?.as_ref()); /// // Only ASCII is allowed; thus if a domain needs to be Punycode-encoded, then it must be _before_ calling /// // this function. - /// assert!(AsciiDomain::try_from("λ.com".to_owned()).is_err()); - /// assert_eq!("xn--wxa.com", AsciiDomain::try_from("xn--wxa.com".to_owned())?.as_ref()); + /// assert!(AsciiDomain::try_from("λ.com".to_owned().into_bytes()).is_err()); + /// assert_eq!("xn--wxa.com", AsciiDomain::try_from(b"xn--wxa.com".to_vec())?.as_ref()); /// # Ok::<_, AsciiDomainErr>(()) /// ``` - #[expect( - unsafe_code, - reason = "need to transform uppercase ASCII into lowercase" - )] + #[expect(unsafe_code, reason = "comment justifies correctness")] #[expect( clippy::arithmetic_side_effects, - reason = "comment justifies its correctness" + reason = "comments justify correctness" )] #[inline] - fn try_from(mut value: String) -> Result<Self, Self::Error> { - value - .as_bytes() + fn try_from(mut value: Vec<u8>) -> Result<Self, Self::Error> { + /// Value to add to an uppercase ASCII `u8` to get the lowercase version. + const DIFF: u8 = b'a' - b'A'; + let bytes = value.as_slice(); + bytes + .as_ref() .last() .ok_or(AsciiDomainErr::Empty) .and_then(|b| { - let len = value.len(); + let len = bytes.len(); if *b == b'.' { if len == 1 { Err(AsciiDomainErr::RootDomain) @@ -403,13 +440,9 @@ impl TryFrom<String> for AsciiDomain { } }) .and_then(|()| { - // SAFETY: - // The only possible mutation we perform is converting uppercase ASCII into lowercase which - // is entirely safe since ASCII is a subset of UTF-8, and ASCII characters are always encoded - // as a single UTF-8 code unit. - let utf8 = unsafe { value.as_bytes_mut() }; - utf8.iter_mut() - .try_fold(0u8, |label_len, byt| { + value + .iter_mut() + .try_fold(0u8, |mut label_len, byt| { let b = *byt; if b == b'.' { if label_len == 0 { @@ -419,18 +452,156 @@ impl TryFrom<String> for AsciiDomain { } } else if label_len == 63 { Err(AsciiDomainErr::LabelLen) - } else if b.is_ascii() { - *byt = b.to_ascii_lowercase(); - // This won't overflow since `label_len < 63`. - Ok(label_len + 1) } else { - Err(AsciiDomainErr::NotAscii) + // We know `label_len` is less than 63, thus this won't overflow. + label_len += 1; + match *byt { + // Non-uppercase ASCII is allowed and doesn't need to be converted. + ..b'A' | b'['..=0x7F => Ok(label_len), + // Uppercase ASCII is allowed but needs to be transformed into lowercase. + b'A'..=b'Z' => { + // Lowercase ASCII is a contiguous block starting from `b'a'` as is uppercase + // ASCII which starts from `b'A'` with uppercase ASCII coming before; thus we + // simply need to shift by a fixed amount. + *byt += DIFF; + Ok(label_len) + } + // Non-ASCII is disallowed. + 0x80.. => Err(AsciiDomainErr::NotAscii), + } } }) - .map(|_| Self(value)) + .map(|_| { + // SAFETY: + // We just verified `value` only contains ASCII; thus this is safe. + let utf8 = unsafe { String::from_utf8_unchecked(value) }; + Self(utf8) + }) }) } } +impl TryFrom<String> for AsciiDomain { + type Error = AsciiDomainErr; + /// Same as [`Self::try_from`] except `value` is a `String`. + #[inline] + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::try_from(value.into_bytes()) + } +} +/// Similar to [`AsciiDomain`] except the contained data is a `&'static str`. +/// +/// Since [`Self::new`] and [`Option::unwrap`] are `const fn`s, one can define a global `const` or `static` +/// variable that represents the RP ID. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AsciiDomainStatic(&'static str); +impl AsciiDomainStatic { + /// Returns the contained `str`. + #[inline] + #[must_use] + pub const fn as_str(self) -> &'static str { + self.0 + } + /// Verifies `domain` is a valid lowercase ASCII domain returning `None` when not valid or when + /// uppercase ASCII exists. + /// + /// Read [`AsciiDomain`] for more information about what constitutes a valid domain. + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::{AsciiDomainStatic, RpId}; + /// /// RP ID of our application. + /// const RP_IP: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); + /// ``` + #[expect( + clippy::arithmetic_side_effects, + reason = "comment justifies correctness" + )] + #[expect( + clippy::else_if_without_else, + reason = "part of if branch and else branch are the same" + )] + #[inline] + #[must_use] + pub const fn new(domain: &'static str) -> Option<Self> { + let mut utf8 = domain.as_bytes(); + if let Some(lst) = utf8.last() { + let len = utf8.len(); + if *lst == b'.' { + if len == 1 || len > 254 { + return None; + } + } else if len > 253 { + return None; + } + let mut label_len = 0; + while let [first, ref rest @ ..] = *utf8 { + if first == b'.' { + if label_len == 0 { + return None; + } + label_len = 0; + } else if label_len == 63 { + return None; + } else { + match first { + // Any non-uppercase ASCII is allowed. + // We know `label_len` is less than 63, so this won't overflow. + ..b'A' | b'['..=0x7F => label_len += 1, + // Uppercase ASCII and non-ASCII are disallowed. + b'A'..=b'Z' | 0x80.. => return None, + } + } + utf8 = rest; + } + Some(Self(domain)) + } else { + None + } + } +} +impl AsRef<str> for AsciiDomainStatic { + #[inline] + fn as_ref(&self) -> &str { + self.as_str() + } +} +impl Borrow<str> for AsciiDomainStatic { + #[inline] + fn borrow(&self) -> &str { + self.as_str() + } +} +impl From<AsciiDomainStatic> for &'static str { + #[inline] + fn from(value: AsciiDomainStatic) -> Self { + value.0 + } +} +impl From<AsciiDomainStatic> for String { + #[inline] + fn from(value: AsciiDomainStatic) -> Self { + value.0.to_owned() + } +} +impl From<AsciiDomainStatic> for AsciiDomain { + #[inline] + fn from(value: AsciiDomainStatic) -> Self { + Self(value.0.to_owned()) + } +} +impl PartialEq<&Self> for AsciiDomainStatic { + #[inline] + fn eq(&self, other: &&Self) -> bool { + *self == **other + } +} +impl PartialEq<AsciiDomainStatic> for &AsciiDomainStatic { + #[inline] + fn eq(&self, other: &AsciiDomainStatic) -> bool { + **self == *other + } +} /// The output of the [URL serializer](https://url.spec.whatwg.org/#concept-url-serializer). /// /// The returned URL must consist of a [scheme](https://url.spec.whatwg.org/#concept-url-scheme) and @@ -495,6 +666,11 @@ pub enum RpId { /// and will likely be relaxed in a [future version](https://github.com/w3c/webauthn/issues/2206); thus /// any ASCII domain is allowed. Domain(AsciiDomain), + /// Similar to [`Self::Domain`] except the ASCII domain is static. + /// + /// Since [`AsciiDomainStatic::new`] is a `const fn`, one can define a `const` or `static` global variable + /// the contains the RP ID. + StaticDomain(AsciiDomainStatic), /// A URL with only scheme and path. Url(Url), } @@ -513,6 +689,7 @@ impl AsRef<str> for RpId { fn as_ref(&self) -> &str { match *self { Self::Domain(ref dom) => dom.as_ref(), + Self::StaticDomain(dom) => dom.as_str(), Self::Url(ref url) => url.as_ref(), } } @@ -522,6 +699,7 @@ impl Borrow<str> for RpId { fn borrow(&self) -> &str { match *self { Self::Domain(ref dom) => dom.borrow(), + Self::StaticDomain(dom) => dom.as_str(), Self::Url(ref url) => url.borrow(), } } @@ -531,6 +709,7 @@ impl From<RpId> for String { fn from(value: RpId) -> Self { match value { RpId::Domain(dom) => dom.into(), + RpId::StaticDomain(dom) => dom.into(), RpId::Url(url) => url.into(), } } @@ -547,6 +726,24 @@ impl PartialEq<RpId> for &RpId { **self == *other } } +impl From<AsciiDomain> for RpId { + #[inline] + fn from(value: AsciiDomain) -> Self { + Self::Domain(value) + } +} +impl From<AsciiDomainStatic> for RpId { + #[inline] + fn from(value: AsciiDomainStatic) -> Self { + Self::StaticDomain(value) + } +} +impl From<Url> for RpId { + #[inline] + fn from(value: Url) -> Self { + Self::Url(value) + } +} /// A URI scheme. This can be used to make /// [origin validation](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin) more convenient. #[derive(Clone, Copy, Debug, Default)] @@ -1402,6 +1599,9 @@ trait Ceremony<const USER_LEN: usize, const DISCOVERABLE: bool> { // Steps 9 and 12 of the registration and authentication ceremonies // respectively. RpId::Url(ref url) => url == client_data_json.origin, + RpId::StaticDomain(dom) => { + DomainOrigin::new(dom.0) == client_data_json.origin + } } { Ok(()) } else { @@ -1539,6 +1739,7 @@ impl PartialEq for PrfInput<'_, '_> { } #[cfg(test)] mod tests { + use super::AsciiDomainStatic; #[cfg(feature = "custom")] use super::{ super::{ @@ -1558,7 +1759,7 @@ mod tests { }, }, }, - AsciiDomain, Challenge, Credentials, ExtensionInfo, ExtensionReq, PrfInput, + Challenge, Credentials, ExtensionInfo, ExtensionReq, PrfInput, PublicKeyCredentialDescriptor, RpId, UserVerificationRequirement, auth::{ AllowedCredential, AllowedCredentials, AuthenticationVerificationOptions, @@ -1603,12 +1804,48 @@ mod tests { #[cfg(feature = "custom")] const CBOR_TRUE: u8 = CBOR_SIMPLE | 21; #[test] + fn ascii_domain_static() { + /// No trailing dot, max label length, max domain length. + const LONG: AsciiDomainStatic = AsciiDomainStatic::new( + "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww", + ) + .unwrap(); + /// Trailing dot, min label length, max domain length. + const LONG_TRAILING: AsciiDomainStatic = AsciiDomainStatic::new("w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.").unwrap(); + /// Single character domain. + const SHORT: AsciiDomainStatic = AsciiDomainStatic::new("w").unwrap(); + let long_label = "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"; + assert_eq!(long_label.len(), 63); + let mut long = format!("{long_label}.{long_label}.{long_label}.{long_label}"); + _ = long.pop(); + _ = long.pop(); + assert_eq!(LONG.0.len(), 253); + assert_eq!(LONG.0, long.as_str()); + let trailing = "w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w.w."; + assert_eq!(LONG_TRAILING.0.len(), 254); + assert_eq!(LONG_TRAILING.0, trailing); + assert_eq!(SHORT.0.len(), 1); + assert_eq!(SHORT.0, "w"); + assert!(AsciiDomainStatic::new("www.Example.com").is_none()); + assert!(AsciiDomainStatic::new("").is_none()); + assert!(AsciiDomainStatic::new(".").is_none()); + assert!(AsciiDomainStatic::new("www..c").is_none()); + let too_long_label = "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"; + assert_eq!(too_long_label.len(), 64); + assert!(AsciiDomainStatic::new(too_long_label).is_none()); + let dom_254_no_trailing_dot = "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww.wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"; + assert_eq!(dom_254_no_trailing_dot.len(), 254); + assert!(AsciiDomainStatic::new(dom_254_no_trailing_dot).is_none()); + assert!(AsciiDomainStatic::new("λ.com").is_none()); + } + #[cfg(feature = "custom")] + const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); + #[test] #[cfg(feature = "custom")] fn eddsa_reg() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let id = UserHandle::from([0]); let mut opts = CredentialCreationOptions::passkey( - &rp_id, + RP_ID, PublicKeyCredentialUserEntity { name: "foo".try_into()?, id: &id, @@ -1935,13 +2172,13 @@ mod tests { 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()); + .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); assert!(matches!(opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &Registration { response: AuthenticatorAttestation::new( client_data_json, @@ -1961,7 +2198,6 @@ mod tests { #[test] #[cfg(feature = "custom")] fn eddsa_auth() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let mut creds = AllowedCredentials::with_capacity(1); _ = creds.push(AllowedCredential { credential: PublicKeyCredentialDescriptor { @@ -1976,7 +2212,7 @@ 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 = AuthExt { prf: None }; @@ -2129,14 +2365,14 @@ mod tests { .as_slice(), ); authenticator_data[..32] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .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()); let ed_priv = SigningKey::from([0; 32]); let sig = ed_priv.sign(authenticator_data.as_slice()).to_vec(); authenticator_data.truncate(132); assert!(!opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &NonDiscoverableAuthentication { raw_id: CredentialId::try_from(vec![0; 16])?, response: NonDiscoverableAuthenticatorAssertion::with_user( @@ -2176,10 +2412,9 @@ mod tests { #[test] #[cfg(feature = "custom")] fn es256_reg() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let id = UserHandle::from([0]); let mut opts = CredentialCreationOptions::passkey( - &rp_id, + RP_ID, PublicKeyCredentialUserEntity { name: "foo".try_into()?, id: &id, @@ -2398,7 +2633,7 @@ mod tests { .as_slice(), ); attestation_object[30..62] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .copy_from_slice(Sha256::digest(RP_ID.as_ref().as_bytes()).as_slice()); let p256_key = P256Key::from_bytes( &[ 137, 133, 36, 206, 163, 47, 255, 5, 76, 144, 163, 141, 40, 109, 108, 240, 246, 115, @@ -2414,7 +2649,7 @@ mod tests { attestation_object[111..143].copy_from_slice(x); attestation_object[146..].copy_from_slice(y); assert!(matches!(opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &Registration { response: AuthenticatorAttestation::new( client_data_json, @@ -2434,8 +2669,7 @@ mod tests { #[test] #[cfg(feature = "custom")] fn es256_auth() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); - let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); + let mut opts = DiscoverableCredentialRequestOptions::passkey(RP_ID); opts.public_key.challenge = Challenge(0); 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. @@ -2489,7 +2723,7 @@ mod tests { .as_slice(), ); authenticator_data[..32] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .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()); let p256_key = P256Key::from_bytes( @@ -2504,7 +2738,7 @@ mod tests { let pub_key = p256_key.verifying_key().to_encoded_point(true); authenticator_data.truncate(37); assert!(!opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &DiscoverableAuthentication { raw_id: CredentialId::try_from(vec![0; 16])?, response: DiscoverableAuthenticatorAssertion::new( @@ -2545,10 +2779,9 @@ mod tests { #[test] #[cfg(feature = "custom")] fn es384_reg() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let id = UserHandle::from([0]); let mut opts = CredentialCreationOptions::passkey( - &rp_id, + RP_ID, PublicKeyCredentialUserEntity { name: "foo".try_into()?, id: &id, @@ -2801,7 +3034,7 @@ mod tests { .as_slice(), ); attestation_object[30..62] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .copy_from_slice(Sha256::digest(RP_ID.as_ref().as_bytes()).as_slice()); let p384_key = P384Key::from_bytes( &[ 158, 99, 156, 49, 190, 211, 85, 167, 28, 2, 80, 57, 31, 22, 17, 38, 85, 78, 232, @@ -2818,7 +3051,7 @@ mod tests { attestation_object[112..160].copy_from_slice(x); attestation_object[163..].copy_from_slice(y); assert!(matches!(opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &Registration { response: AuthenticatorAttestation::new( client_data_json, @@ -2838,8 +3071,7 @@ mod tests { #[test] #[cfg(feature = "custom")] fn es384_auth() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); - let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); + let mut opts = DiscoverableCredentialRequestOptions::passkey(RP_ID); opts.public_key.challenge = Challenge(0); 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. @@ -2893,7 +3125,7 @@ mod tests { .as_slice(), ); authenticator_data[..32] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .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()); let p384_key = P384Key::from_bytes( @@ -2909,7 +3141,7 @@ mod tests { let pub_key = p384_key.verifying_key().to_encoded_point(true); authenticator_data.truncate(37); assert!(!opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &DiscoverableAuthentication { raw_id: CredentialId::try_from(vec![0; 16])?, response: DiscoverableAuthenticatorAssertion::new( @@ -2950,10 +3182,9 @@ mod tests { #[test] #[cfg(feature = "custom")] fn rs256_reg() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let id = UserHandle::from([0]); let mut opts = CredentialCreationOptions::passkey( - &rp_id, + RP_ID, PublicKeyCredentialUserEntity { name: "foo".try_into()?, id: &id, @@ -3365,7 +3596,7 @@ mod tests { .as_slice(), ); attestation_object[31..63] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .copy_from_slice(Sha256::digest(RP_ID.as_ref().as_bytes()).as_slice()); let n = [ 111, 183, 124, 133, 38, 167, 70, 148, 44, 50, 30, 60, 121, 14, 38, 37, 96, 114, 107, 195, 248, 64, 79, 36, 237, 140, 43, 27, 94, 74, 102, 152, 135, 102, 184, 150, 186, 206, @@ -3438,7 +3669,7 @@ mod tests { let n = rsa_key.as_ref().n().to_bytes_be(); attestation_object[113..369].copy_from_slice(n.as_slice()); assert!(matches!(opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &Registration { response: AuthenticatorAttestation::new( client_data_json, @@ -3458,8 +3689,7 @@ mod tests { #[test] #[cfg(feature = "custom")] fn rs256_auth() -> Result<(), AggErr> { - let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); - let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); + let mut opts = DiscoverableCredentialRequestOptions::passkey(RP_ID); opts.public_key.challenge = Challenge(0); 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. @@ -3513,7 +3743,7 @@ mod tests { .as_slice(), ); authenticator_data[..32] - .copy_from_slice(Sha256::digest(rp_id.as_ref().as_bytes()).as_slice()); + .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()); let n = [ @@ -3588,7 +3818,7 @@ mod tests { let sig = rsa_key.sign(authenticator_data.as_slice()).to_vec(); authenticator_data.truncate(37); assert!(!opts.start_ceremony()?.0.verify( - &rp_id, + RP_ID, &DiscoverableAuthentication { raw_id: CredentialId::try_from(vec![0; 16])?, response: DiscoverableAuthenticatorAssertion::new( diff --git a/src/request/auth.rs b/src/request/auth.rs @@ -8,7 +8,7 @@ use super::{ DynamicState, StaticState, }, }, - AsciiDomain, DomainOrigin, Url, + AsciiDomain, AsciiDomainStatic, DomainOrigin, Url, register::{self, PublicKeyCredentialCreationOptions}, }; use super::{ @@ -365,6 +365,25 @@ impl<'rp_id, 'prf_first, 'prf_second> ) }) } + /// Same as [`Self::start_ceremony`] except the raw challenge is returned instead of + /// [`DiscoverableAuthenticationClientState`]. + /// + /// Note this is useful when one configures the authentication ceremony client-side and only needs the + /// server-generated challenge. It's of course essential that `self` is configured exactly the same as + /// how it is configured client-side. See + /// [`challengeURL`](https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-challengeURL) for more + /// information. + /// + /// # Errors + /// + /// Read [`Self::start_ceremony`]. + #[inline] + pub fn start_ceremony_challenge_only( + self, + ) -> Result<(DiscoverableAuthenticationServerState, [u8; 16]), InvalidTimeout> { + self.start_ceremony() + .map(|(server, client)| (server, client.0.public_key.challenge.into_array())) + } } /// The [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions) /// to send to the client when authenticating non-discoverable credententials. @@ -987,9 +1006,10 @@ pub struct AuthenticationVerificationOptions<'origins, 'top_origins, O, T> { /// Origins to use for [origin validation](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin). /// /// When this is empty, the origin that will be used will be based on the [`RpId`] passed to - /// [`DiscoverableAuthenticationServerState::verify`] or [`NonDiscoverableAuthenticationServerState::verify`]. If - /// [`RpId::Domain`], then the [`DomainOrigin`] returned from passing [`AsciiDomain::as_ref`] to - /// [`DomainOrigin::new`] will be used; otherwise the [`Url`] in [`RpId::Url`] will be used. + /// [`DiscoverableAuthenticationServerState::verify`] or [`NonDiscoverableAuthenticationServerState::verify`]. + /// If [`RpId::Domain`] or [`RpId::StaticDomain`], then the [`DomainOrigin`] returned from passing + /// [`AsciiDomain::as_ref`] and [`AsciiDomainStatic::as_str`] to [`DomainOrigin::new`] respectively will be + /// used; otherwise the [`Url`] in [`RpId::Url`] will be used. pub allowed_origins: &'origins [O], /// [Top-level origins](https://html.spec.whatwg.org/multipage/webappapis.html#concept-environment-top-level-origin) /// to use for [origin validation](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin). @@ -1019,27 +1039,37 @@ pub struct AuthenticationVerificationOptions<'origins, 'top_origins, O, T> { #[cfg(feature = "serde_relaxed")] pub client_data_json_relaxed: bool, } -impl<O, T> Default for AuthenticationVerificationOptions<'_, '_, O, T> { +impl<O, T> AuthenticationVerificationOptions<'_, '_, O, T> { /// Returns `Self` such that [`Self::allowed_origins`] is empty, [`Self::allowed_top_origins`] is `None`, /// [`Self::backup_requirement`] is `None`, [`Self::error_on_unsolicited_extensions`] is `true`, /// [`Self::auth_attachment_enforcement`] is [`AuthenticatorAttachmentEnforcement::default`], /// [`Self::update_uv`] is `false`, [`Self::sig_counter_enforcement`] is /// [`SignatureCounterEnforcement::default`], and [`Self::client_data_json_relaxed`] is `true`. + /// + /// Note `O` and `T` should implement `PartialEq<Origin<'_>>` (e.g., `&str`). #[inline] - fn default() -> Self { + #[must_use] + pub const fn new() -> Self { Self { - allowed_origins: &[], + allowed_origins: [].as_slice(), allowed_top_origins: None, backup_requirement: None, error_on_unsolicited_extensions: true, - auth_attachment_enforcement: AuthenticatorAttachmentEnforcement::default(), + auth_attachment_enforcement: AuthenticatorAttachmentEnforcement::Ignore(false), update_uv: false, - sig_counter_enforcement: SignatureCounterEnforcement::default(), + sig_counter_enforcement: SignatureCounterEnforcement::Fail, #[cfg(feature = "serde_relaxed")] client_data_json_relaxed: true, } } } +impl<O, T> Default for AuthenticationVerificationOptions<'_, '_, O, T> { + /// Same as [`Self::new`]. + #[inline] + fn default() -> Self { + Self::new() + } +} // This is essentially the `DiscoverableCredentialRequestOptions` used to create it; however to reduce // memory usage, we remove all unnecessary data making an instance of this 48 bytes in size // `x86_64-unknown-linux-gnu` platforms. diff --git a/src/request/error.rs b/src/request/error.rs @@ -7,7 +7,7 @@ use core::{ fmt::{self, Display, Formatter}, num::ParseIntError, }; -/// Error returned by [`AsciiDomain::try_from`] when the `String` is not a valid ASCII domain. +/// Error returned by [`AsciiDomain::try_from`] when the `Vec` is not a valid ASCII domain. #[derive(Clone, Copy, Debug)] pub enum AsciiDomainErr { /// Variant returned when the domain is empty. diff --git a/src/request/register.rs b/src/request/register.rs @@ -19,7 +19,9 @@ use super::{ }; #[cfg(doc)] use crate::{ - request::{AsciiDomain, DomainOrigin, Url, auth::PublicKeyCredentialRequestOptions}, + request::{ + AsciiDomain, AsciiDomainStatic, DomainOrigin, Url, auth::PublicKeyCredentialRequestOptions, + }, response::{AuthTransports, AuthenticatorTransport, Backup, CollectedClientData}, }; use alloc::borrow::Cow; @@ -1913,9 +1915,9 @@ pub struct RegistrationVerificationOptions<'origins, 'top_origins, O, T> { /// Origins to use for [origin validation](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin). /// /// When this is empty, the origin that will be used will be based on - /// the [`RpId`] passed to [`RegistrationServerState::verify`]. If [`RpId::Domain`], then the [`DomainOrigin`] returned from - /// passing [`AsciiDomain::as_ref`] to [`DomainOrigin::new`] will be used; otherwise the [`Url`] in - /// [`RpId::Url`] will be used. + /// the [`RpId`] passed to [`RegistrationServerState::verify`]. If [`RpId::Domain`] or [`RpId::StaticDomain`], + /// then the [`DomainOrigin`] returned from passing [`AsciiDomain::as_ref`] and [`AsciiDomainStatic::as_str`] + /// to [`DomainOrigin::new`] respectively will be used; otherwise the [`Url`] in [`RpId::Url`] will be used. pub allowed_origins: &'origins [O], /// [Top-level origins](https://html.spec.whatwg.org/multipage/webappapis.html#concept-environment-top-level-origin) /// to use for [origin validation](https://www.w3.org/TR/webauthn-3/#sctn-validating-origin). @@ -1935,11 +1937,29 @@ pub struct RegistrationVerificationOptions<'origins, 'top_origins, O, T> { #[cfg(feature = "serde_relaxed")] pub client_data_json_relaxed: bool, } -impl<O, T> Default for RegistrationVerificationOptions<'_, '_, O, T> { +impl<O, T> RegistrationVerificationOptions<'_, '_, O, T> { /// Returns `Self` such that [`Self::allowed_origins`] is empty, [`Self::allowed_top_origins`] is `None`, /// [`Self::backup_requirement`] is [`BackupReq::None`], [`Self::error_on_unsolicited_extensions`] is `true`, /// [`Self::require_authenticator_attachment`] is `false`, and [`Self::client_data_json_relaxed`] is /// `true`. + /// + /// Note `O` and `T` should implement `PartialEq<Origin<'_>>` (e.g., `&str`). + #[inline] + #[must_use] + pub const fn new() -> Self { + Self { + allowed_origins: [].as_slice(), + allowed_top_origins: None, + backup_requirement: BackupReq::None, + error_on_unsolicited_extensions: true, + require_authenticator_attachment: false, + #[cfg(feature = "serde_relaxed")] + client_data_json_relaxed: true, + } + } +} +impl<O, T> Default for RegistrationVerificationOptions<'_, '_, O, T> { + /// Same as [`Self::new`]. #[inline] fn default() -> Self { Self { @@ -2212,7 +2232,7 @@ impl PartialEq for ServerExtensionInfo { } // This is essentially the `PublicKeyCredentialCreationOptions` used to create it; however to reduce // memory usage, we remove all unnecessary data making an instance of this 48 bytes in size on -// `x86_64-unknown-linux-gnu` platforms. +// `x86_64-unknown-linux-gnu` platforms when `USER_LEN` is `USER_HANDLE_MIN_LEN`. /// State needed to be saved when beginning the registration ceremony. /// /// Saves the necessary information associated with the [`CredentialCreationOptions`] used to create it diff --git a/src/request/ser.rs b/src/request/ser.rs @@ -48,8 +48,7 @@ impl Serialize for Challenge { /// Serializes `self` to conform with /// [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-challenge). /// - /// Specifically `self` is interpreted as a little-endian array of 16 bytes that is then transformed into a - /// base64url-encoded string. + /// Specifically [`Self::as_array`] is transformed into a base64url-encoded string. /// /// # Examples /// @@ -59,17 +58,13 @@ impl Serialize for Challenge { /// assert_eq!(serde_json::to_string(&Challenge::new())?.len(), 24); /// # Ok::<_, serde_json::Error>(()) /// ``` - #[expect( - clippy::little_endian_bytes, - reason = "SentChallenge::deserialize and Challenge::serialize need to be consistent across architectures" - )] #[inline] fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str( - self.0.to_le_bytes().as_slice(), + self.as_array().as_slice(), [0; Self::BASE64_LEN].as_mut_slice(), )) } diff --git a/src/response.rs b/src/response.rs @@ -33,8 +33,8 @@ use ser_relaxed::SerdeJsonErr; /// # use data_encoding::BASE64URL_NOPAD; /// # use webauthn_rp::{ /// # hash::hash_set::FixedCapHashSet, -/// # request::{auth::{error::InvalidTimeout, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, error::AsciiDomainErr, register::{UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, AsciiDomain, BackupReq, RpId}, -/// # response::{auth::{error::AuthCeremonyErr, DiscoverableAuthentication64}, error::CollectedClientDataErr, register::{AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, CompressedPubKey, StaticState}, AuthenticatorAttachment, Backup, CollectedClientData, CredentialId}, +/// # request::{auth::{error::InvalidTimeout, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, error::AsciiDomainErr, register::{UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, AsciiDomainStatic, BackupReq, RpId}, +/// # response::{auth::{error::AuthCeremonyErr, DiscoverableAuthentication64}, error::CollectedClientDataErr, register::{AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, CompressedPubKeyOwned, StaticState}, AuthenticatorAttachment, Backup, CollectedClientData, CredentialId}, /// # AuthenticatedCredential, CredentialErr /// # }; /// # #[derive(Debug)] @@ -79,9 +79,9 @@ use ser_relaxed::SerdeJsonErr; /// # Self::AuthCeremony(value) /// # } /// # } +/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); /// let mut ceremonies = FixedCapHashSet::new(128); -/// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); -/// let (server, client) = DiscoverableCredentialRequestOptions::passkey(&rp_id).start_ceremony()?; +/// let (server, client) = DiscoverableCredentialRequestOptions::passkey(RP_ID).start_ceremony()?; /// assert!( /// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) /// ); @@ -94,7 +94,7 @@ use ser_relaxed::SerdeJsonErr; /// # #[cfg(all(feature = "custom", feature = "serde"))] /// let mut cred = AuthenticatedCredential::new(authentication.raw_id(), &user_handle, static_state, dynamic_state)?; /// # #[cfg(all(feature = "custom", feature = "serde"))] -/// if ceremonies.take(&authentication.challenge()?).ok_or(E::MissingCeremony)?.verify(&rp_id, &authentication, &mut cred, &AuthenticationVerificationOptions::<&str, &str>::default())? { +/// if ceremonies.take(&authentication.challenge()?).ok_or(E::MissingCeremony)?.verify(RP_ID, &authentication, &mut cred, &AuthenticationVerificationOptions::<&str, &str>::default())? { /// update_cred(authentication.raw_id(), cred.dynamic_state()); /// } /// /// Send `DiscoverableAuthenticationClientState` and receive `DiscoverableAuthentication64` JSON from client. @@ -121,9 +121,9 @@ use ser_relaxed::SerdeJsonErr; /// # }).to_string() /// } /// /// Gets the `AuthenticatedCredential` parts associated with `id` and `user_handle` from the database. -/// fn get_credential(id: CredentialId<&[u8]>, user_handle: &UserHandle64) -> Option<(StaticState<CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>>, DynamicState)> { +/// fn get_credential(id: CredentialId<&[u8]>, user_handle: &UserHandle64) -> Option<(StaticState<CompressedPubKeyOwned>, DynamicState)> { /// // ⋮ -/// # Some((StaticState { credential_public_key: CompressedPubKey::Ed25519(Ed25519PubKey::from([0; 32])), extensions: AuthenticatorExtensionOutputStaticState { cred_protect: CredentialProtectionPolicy::UserVerificationRequired, hmac_secret: None, }, client_extension_results: ClientExtensionsOutputsStaticState { prf: None, }, }, DynamicState { user_verified: true, backup: Backup::NotEligible, sign_count: 1, authenticator_attachment: AuthenticatorAttachment::None })) +/// # Some((StaticState { credential_public_key: CompressedPubKeyOwned::Ed25519(Ed25519PubKey::from([0; 32])), extensions: AuthenticatorExtensionOutputStaticState { cred_protect: CredentialProtectionPolicy::UserVerificationRequired, hmac_secret: None, }, client_extension_results: ClientExtensionsOutputsStaticState { prf: None, }, }, DynamicState { user_verified: true, backup: Backup::NotEligible, sign_count: 1, authenticator_attachment: AuthenticatorAttachment::None })) /// } /// /// Updates the current `DynamicState` associated with `id` in the database to /// /// `dyn_state`. @@ -156,7 +156,7 @@ pub mod error; /// # use data_encoding::BASE64URL_NOPAD; /// # use webauthn_rp::{ /// # hash::hash_set::FixedCapHashSet, -/// # request::{register::{error::CreationOptionsErr, CredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationClientState, UserHandle, UserHandle64, USER_HANDLE_MAX_LEN, RegistrationVerificationOptions}, error::AsciiDomainErr, AsciiDomain, PublicKeyCredentialDescriptor, RpId}, +/// # request::{register::{error::CreationOptionsErr, CredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationClientState, UserHandle, UserHandle64, USER_HANDLE_MAX_LEN, RegistrationVerificationOptions}, error::AsciiDomainErr, AsciiDomainStatic, PublicKeyCredentialDescriptor, RpId}, /// # response::{register::{error::RegCeremonyErr, Registration}, error::CollectedClientDataErr, CollectedClientData}, /// # RegisteredCredential /// # }; @@ -194,9 +194,9 @@ pub mod error; /// # Self::RegCeremony(value) /// # } /// # } +/// const RP_ID: &RpId = &RpId::StaticDomain(AsciiDomainStatic::new("example.com").unwrap()); /// # #[cfg(feature = "custom")] /// let mut ceremonies = FixedCapHashSet::new(128); -/// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// # #[cfg(feature = "custom")] /// let user_handle = get_user_handle(); /// # #[cfg(feature = "custom")] @@ -204,7 +204,7 @@ pub mod error; /// # #[cfg(feature = "custom")] /// let creds = get_registered_credentials(user_handle); /// # #[cfg(feature = "custom")] -/// let (server, client) = CredentialCreationOptions::passkey(&rp_id, user, creds).start_ceremony()?; +/// let (server, client) = CredentialCreationOptions::passkey(RP_ID, user, creds).start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( /// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) @@ -213,7 +213,7 @@ pub mod error; /// let registration = serde_json::from_str::<Registration>(get_registration_json(client).as_str())?; /// let ver_opts = RegistrationVerificationOptions::<&str, &str>::default(); /// # #[cfg(all(feature = "custom", feature = "serde_relaxed"))] -/// insert_cred(ceremonies.take(&registration.challenge()?).ok_or(E::MissingCeremony)?.verify(&rp_id, &registration, &ver_opts)?); +/// insert_cred(ceremonies.take(&registration.challenge()?).ok_or(E::MissingCeremony)?.verify(RP_ID, &registration, &ver_opts)?); /// /// Extract `UserHandle` from session cookie if this is not the first credential registered. /// # #[cfg(feature = "custom")] /// fn get_user_handle() -> UserHandle64 { @@ -606,6 +606,34 @@ impl<T: Ord> Ord for CredentialId<T> { /// Copy of [`Challenge`] sent back from the client. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct SentChallenge(pub u128); +impl SentChallenge { + /// Transforms `value` into a `SentChallenge` by interpreting `value` as a + /// little-endian `u128`. + #[expect(clippy::little_endian_bytes, reason = "Challenge and SentChallenge need to be compatible, and we need to ensure the data is sent and received in the same order")] + #[inline] + #[must_use] + pub const fn from_array(value: [u8; 16]) -> Self { + Self(u128::from_le_bytes(value)) + } + /// Transforms `value` into a `SentChallenge`. + #[inline] + #[must_use] + pub const fn from_challenge(value: Challenge) -> Self { + Self(value.into_data()) + } +} +impl From<Challenge> for SentChallenge { + #[inline] + fn from(value: Challenge) -> Self { + Self::from_challenge(value) + } +} +impl From<[u8; 16]> for SentChallenge { + #[inline] + fn from(value: [u8; 16]) -> Self { + Self::from_array(value) + } +} impl PartialEq<&Self> for SentChallenge { #[inline] fn eq(&self, other: &&Self) -> bool { diff --git a/src/response/register.rs b/src/response/register.rs @@ -1466,27 +1466,23 @@ impl<'a: 'b, 'b> TryFrom<(&'a [u8], u32)> for RsaPubKey<&'b [u8]> { let zeros = fst.leading_zeros() as usize; if zeros == 8 { Err(RsaPubKeyErr::NLeading0) - } else if let Some(bits) = n.len().checked_mul(8) { - if (MIN_RSA_N_BITS..=MAX_RSA_N_BITS) - // `bits` is at least 8 since `n.len()` is at least 1; thus underflow cannot occur. - .contains(&(bits - zeros)) + // `bits` is at least 8 since `n.len()` is at least 1; thus underflow cannot occur. + } else if let Some(bits) = n.len().checked_mul(8) + && (MIN_RSA_N_BITS..=MAX_RSA_N_BITS).contains(&(bits - zeros)) + { + // We know `n` is not empty, so this won't `panic`. + if n.last() + .unwrap_or_else(|| unreachable!("there is a bug in RsaPubKey::try_from")) + & 1 + == 0 { - // We know `n` is not empty, so this won't `panic`. - if n.last() - .unwrap_or_else(|| unreachable!("there is a bug in RsaPubKey::try_from")) - & 1 - == 0 - { - Err(RsaPubKeyErr::NEven) - } else if e < MIN_RSA_E { - Err(RsaPubKeyErr::ESize) - } else if e & 1 == 0 { - Err(RsaPubKeyErr::EEven) - } else { - Ok(Self(n, e)) - } + Err(RsaPubKeyErr::NEven) + } else if e < MIN_RSA_E { + Err(RsaPubKeyErr::ESize) + } else if e & 1 == 0 { + Err(RsaPubKeyErr::EEven) } else { - Err(RsaPubKeyErr::NSize) + Ok(Self(n, e)) } } else { Err(RsaPubKeyErr::NSize) @@ -1994,6 +1990,15 @@ pub enum CompressedPubKey<T, T2, T3, T4> { /// An alleged RSA public key. Rsa(RsaPubKey<T4>), } +/// `CompressedPubKey` that owns the key data. +pub type CompressedPubKeyOwned = CompressedPubKey< + [u8; ed25519_dalek::PUBLIC_KEY_LENGTH], + [u8; <NistP256 as Curve>::FieldBytesSize::INT], + [u8; <NistP384 as Curve>::FieldBytesSize::INT], + Vec<u8>, +>; +/// `CompressedPubKey` that borrows the key data. +pub type CompressedPubKeyBorrowed<'a> = CompressedPubKey<&'a [u8], &'a [u8], &'a [u8], &'a [u8]>; impl CompressedPubKey<&[u8], &[u8], &[u8], &[u8]> { /// Validates `self` is in fact a valid public key. /// @@ -3410,15 +3415,8 @@ impl<'a: 'b, 'b, T: AsRef<[u8]>, T2: AsRef<[u8]>, T3: AsRef<[u8]>, T4: AsRef<[u8 } /// `StaticState` with an uncompressed [`Self::credential_public_key`]. pub type StaticStateUncompressed<'a> = StaticState<UncompressedPubKey<'a>>; -/// `StaticState` with a compressed [`Self::credential_public_key`]. -pub type StaticStateCompressed = StaticState< - CompressedPubKey< - [u8; ed25519_dalek::PUBLIC_KEY_LENGTH], - [u8; <NistP256 as Curve>::FieldBytesSize::INT], - [u8; <NistP384 as Curve>::FieldBytesSize::INT], - Vec<u8>, - >, ->; +/// `StaticState` with a compressed [`Self::credential_public_key`] that owns the key data. +pub type StaticStateCompressed = StaticState<CompressedPubKeyOwned>; impl StaticStateUncompressed<'_> { /// Transforms `self` into `StaticState` that contains the compressed version of the public key. #[inline] diff --git a/src/response/ser.rs b/src/response/ser.rs @@ -428,10 +428,6 @@ impl<'de> Deserialize<'de> for SentChallenge { clippy::panic_in_result_fn, reason = "we want to crash when there is a bug" )] - #[expect( - clippy::little_endian_bytes, - reason = "SentChallenge::deserialize and Challenge::serialize need to be consistent across architectures" - )] fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E> where E: Error, @@ -443,7 +439,7 @@ impl<'de> Deserialize<'de> for SentChallenge { .map_err(|err| E::custom(err.error)) .map(|len| { assert_eq!(len, 16, "there is a bug in BASE64URL_NOPAD::decode_mut"); - SentChallenge(u128::from_le_bytes(data)) + SentChallenge::from_array(data) }) } else { Err(E::invalid_value(