webauthn_rp

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

commit 88c3f257fcf796e6642a254adbdb3c81cfc214f4
parent 385433ad3d5255e8a8efdb90b2ccbb8befec3b2f
Author: Zack Newman <zack@philomathiclife.com>
Date:   Fri, 23 May 2025 13:14:22 -0600

new hashset. improve prf

Diffstat:
MCargo.toml | 57++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MREADME.md | 97+++++++++++++++++++++++++++++++++++++------------------------------------------
Asrc/hash.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hash/hash_map.rs | 364+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hash/hash_set.rs | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 287++++++++++++++++++++++++++++++++++++-------------------------------------------
Msrc/request.rs | 489+++++++++----------------------------------------------------------------------
Msrc/request/auth.rs | 811++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/request/auth/ser_server_state.rs | 5-----
Msrc/request/register.rs | 1310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/request/register/error.rs | 8+++++---
Msrc/request/register/ser.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/request/register/ser_server_state.rs | 29++++++++++++++++++-----------
Msrc/request/ser.rs | 12++++--------
Msrc/response.rs | 48++++++++++++++++++++++++++++--------------------
Msrc/response/auth.rs | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/response/auth/error.rs | 60++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/response/auth/ser.rs | 4----
Msrc/response/auth/ser_relaxed.rs | 4----
Msrc/response/register.rs | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/response/register/bin.rs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/response/register/error.rs | 21+++++++++------------
Msrc/response/register/ser.rs | 82++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/response/register/ser_relaxed.rs | 25++++++++-----------------
Msrc/response/ser.rs | 23+++++++++++++++--------
Msrc/response/ser_relaxed.rs | 52+++++++++++++++++++++++++---------------------------
26 files changed, 3623 insertions(+), 1211 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -9,27 +9,74 @@ license = "MIT OR Apache-2.0" name = "webauthn_rp" readme = "README.md" repository = "https://git.philomathiclife.com/repos/webauthn_rp/" -rust-version = "1.86.0" -version = "0.3.0" +rust-version = "1.87.0" +version = "0.4.0" + +[lints.rust] +unknown_lints = { level = "deny", priority = -1 } +future_incompatible = { level = "deny", priority = -1 } +let_underscore = { level = "deny", priority = -1 } +missing_docs = { level = "deny", priority = -1 } +nonstandard_style = { level = "deny", priority = -1 } +refining_impl_trait = { level = "deny", priority = -1 } +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 } +unsafe_code = { level = "deny", priority = -1 } +unused = { level = "deny", priority = -1 } +warnings = { level = "deny", priority = -1 } + +[lints.clippy] +all = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } +complexity = { level = "deny", priority = -1 } +correctness = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +restriction = { level = "deny", priority = -1 } +style = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +# Noisy, opinionated, and likely don't prevent bugs or improve APIs. +arbitrary_source_item_ordering = "allow" +blanket_clippy_restriction_lints = "allow" +exhaustive_enums = "allow" +exhaustive_structs = "allow" +implicit_return = "allow" +min_ident_chars = "allow" +missing_trait_methods = "allow" +module_name_repetitions = "allow" +multiple_crate_versions = "allow" +pub_with_shorthand = "allow" +pub_use = "allow" +question_mark_used = "allow" +ref_patterns = "allow" +return_and_then = "allow" +self_named_module_files = "allow" +single_call_fn = "allow" +single_char_lifetime_names = "allow" +unseparated_literal_suffix = "allow" [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] [dependencies] -data-encoding = { version = "2.8.0", default-features = false } +data-encoding = { version = "2.9.0", default-features = false } ed25519-dalek = { version = "2.1.1", default-features = false, features = ["fast"] } +hashbrown = { version = "0.15.3", default-features = false } p256 = { version = "0.13.2", default-features = false, features = ["ecdsa"] } p384 = { version = "0.13.1", default-features = false, features = ["ecdsa"] } precis-profiles = { version = "0.1.12", default-features = false } -rand = { version = "0.9.0", default-features = false, features = ["thread_rng"] } +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 } url = { version = "2.5.4", default-features = false } [dev-dependencies] -data-encoding = { version = "2.8.0", default-features = false, features = ["alloc"] } +data-encoding = { version = "2.9.0", default-features = false, features = ["alloc"] } ed25519-dalek = { version = "2.1.1", 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"] } diff --git a/README.md b/README.md @@ -17,16 +17,18 @@ having said that, there are pre-defined serialization formats for "common" deplo ## `webauthn_rp` in action ```rust +use core::convert; use webauthn_rp::{ - AuthenticatedCredential, DiscoverableAuthentication64, DiscoverableAuthenticationServerState, - DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions, RegisteredCredential, - Registration, RegistrationServerState, + AuthenticatedCredential64, DiscoverableAuthentication64, DiscoverableAuthenticationServerState, + DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions64, RegisteredCredential64, + Registration, RegistrationServerState64, + hash::hash_set::FixedCapHashSet, request::{ AsciiDomain, PublicKeyCredentialDescriptor, RpId, auth::AuthenticationVerificationOptions, register::{ - Nickname, PublicKeyCredentialUserEntity, RegistrationVerificationOptions, - USER_HANDLE_MAX_LEN, UserHandle64, Username, + Nickname, PublicKeyCredentialUserEntity64, RegistrationVerificationOptions, + UserHandle64, Username, }, }, response::{ @@ -35,8 +37,6 @@ use webauthn_rp::{ register::{CompressedPubKey, DynamicState, error::RegCeremonyErr}, }, }; -// These are available iff `serializable_server_state` is _not_ enabled. -use webauthn_rp::request::{FixedCapHashSet, InsertResult}; use serde::de::{Deserialize, Deserializer}; use serde_json::Error as JsonErr; /// The RP ID our application uses. @@ -99,7 +99,7 @@ impl<'de: 'a + 'b, 'a, 'b> Deserialize<'de> for AccountReg<'a, 'b> { /// already exist otherwise. This is similar to credential creation except a random `UserHandle64` is generated and /// will be used for subsequent credential registrations. fn start_account_creation( - reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, + reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, ) -> Result<Vec<u8>, AppErr> { let rp_id = RpId::Domain( AsciiDomain::try_from(RP_ID.to_owned()) @@ -107,19 +107,17 @@ fn start_account_creation( ); let user_id = UserHandle64::new(); let (server, client) = - PublicKeyCredentialCreationOptions::first_passkey_with_blank_user_info( + PublicKeyCredentialCreationOptions64::first_passkey_with_blank_user_info( &rp_id, &user_id, ) .start_ceremony() .unwrap_or_else(|_e| { - unreachable!("we don't manually mutate the options; thus this can't fail") + unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") }); - if matches!( - reg_ceremonies.insert_or_replace_all_expired(server), - InsertResult::Success - ) { + if reg_ceremonies.insert_remove_all_expired(server).is_some_and(convert::identity) + { Ok(serde_json::to_vec(&client) - .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState::serialize"))) + .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState64::serialize"))) } else { Err(AppErr::WebAuthnCeremonyCreation) } @@ -133,7 +131,7 @@ fn start_account_creation( /// Note if this errors, then the user should be notified to delete the passkey created on their /// authenticator. fn finish_account_creation( - reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, + reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, client_data: Vec<u8>, ) -> Result<(), AppErr> { let account = serde_json::from_slice::<AccountReg<'_, '_>>(client_data.as_slice())?; @@ -161,24 +159,22 @@ fn finish_account_creation( /// the authenticator. fn start_cred_registration( user_id: &UserHandle64, - reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, + 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) = PublicKeyCredentialCreationOptions::passkey(&rp_id, entity, creds) + let (server, client) = PublicKeyCredentialCreationOptions64::passkey(&rp_id, entity, creds) .start_ceremony() .unwrap_or_else(|_e| { - unreachable!("we don't manually mutate the options; thus this won't error") + unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") }); - if matches!( - reg_ceremonies.insert_or_replace_all_expired(server), - InsertResult::Success - ) { + if reg_ceremonies.insert_remove_all_expired(server).is_some_and(convert::identity) + { Ok(serde_json::to_vec(&client) - .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState::serialize"))) + .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState64::serialize"))) } else { Err(AppErr::WebAuthnCeremonyCreation) } @@ -192,7 +188,7 @@ fn start_cred_registration( /// Note if this errors, then the user should be notified to delete the passkey created on their /// authenticator. fn finish_cred_registration( - reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, + reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, client_data: Vec<u8>, ) -> Result<(), AppErr> { // `Registration::from_json_custom` is available iff `serde_relaxed` is enabled. @@ -223,12 +219,10 @@ fn start_auth( let (server, client) = DiscoverableCredentialRequestOptions::passkey(&rp_id) .start_ceremony() .unwrap_or_else(|_e| { - unreachable!("we don't manually mutate the options; thus this won't error") + unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") }); - if matches!( - auth_ceremonies.insert_or_replace_all_expired(server), - InsertResult::Success - ) { + if auth_ceremonies.insert_remove_all_expired(server).is_some_and(convert::identity) + { Ok(serde_json::to_vec(&client).unwrap_or_else(|_e| { unreachable!("bug in DiscoverableAuthenticationClientState::serialize") })) @@ -241,7 +235,7 @@ fn finish_auth( auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, client_data: Vec<u8>, ) -> Result<(), AppErr> { - // `Authentication::from_json_custom` is available iff `serde_relaxed` is enabled. + // `DiscoverableAuthentication64::from_json_custom` is available iff `serde_relaxed` is enabled. let authentication = DiscoverableAuthentication64::from_json_custom(client_data.as_slice())?; let mut cred = select_credential( @@ -250,7 +244,7 @@ fn finish_auth( )? .ok_or_else(|| AppErr::NoCredential)?; if auth_ceremonies - // `Authentication::challenge_relaxed` is available iff `serde_relaxed` is enabled. + // `DiscoverableAuthentication64::challenge_relaxed` is available iff `serde_relaxed` is enabled. .take(&authentication.challenge_relaxed()?) .ok_or(AppErr::MissingWebAuthnCeremony)? .verify( @@ -276,7 +270,7 @@ fn finish_auth( /// `CredentialId`, or there already exists an account using the same `UserHandle64`. fn insert_account( account: &AccountReg<'_, '_>, - cred: RegisteredCredential<'_, USER_HANDLE_MAX_LEN>, + cred: RegisteredCredential64<'_>, ) -> Result<(), AppErr> { // ⋮ } @@ -289,7 +283,7 @@ fn select_user_info<'a>( user_id: &'a UserHandle64, ) -> Result< Option<( - PublicKeyCredentialUserEntity<'static, 'static, 'a, USER_HANDLE_MAX_LEN>, + PublicKeyCredentialUserEntity64<'static, 'static, 'a>, Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, )>, AppErr, @@ -300,9 +294,10 @@ fn select_user_info<'a>( /// /// # Errors /// -/// Errors iff writing `cred` errors or there already exists a credential using the same `CredentialId`. +/// Errors iff writing `cred` errors, there already exists a credential using the same `CredentialId`, +/// or there does not exist an account under the `UserHandle64`. fn insert_credential( - cred: RegisteredCredential<'_, USER_HANDLE_MAX_LEN>, + cred: RegisteredCredential64<'_>, ) -> Result<(), AppErr> { // ⋮ } @@ -317,10 +312,9 @@ fn select_credential<'cred, 'user>( user_id: &'user UserHandle64, ) -> Result< Option< - AuthenticatedCredential< + AuthenticatedCredential64< 'cred, 'user, - USER_HANDLE_MAX_LEN, CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>, >, >, @@ -347,7 +341,6 @@ fn update_credential( will occur. ### `bin` - Enables binary (de)serialization via `Encode` and `Decode`. Since registered credentials will almost always have to be saved to persistent storage, _some_ form of (de)serialization is necessary. In the event `bin` is unsuitable or only partially suitable (e.g., human-readable output is desired), one will need to enable @@ -405,13 +398,14 @@ wants to accommodate non-conforming clients or clients that implement older vers ### `serializable_server_state` Automatically enables [`bin`](#bin) in addition to `Encode` and `Decode` implementations for -`RegistrationServerState` and `AuthenticationServerState`. Less accurate `SystemTime` is used instead of -`Instant` for timeout enforcement. This should be enabled if you don't desire to use in-memory collections to -store the instances of those types. +`RegistrationServerState`, `DiscoverableAuthenticationServerState`, and +`NonDiscoverableAuthenticationServerState`. Less accurate `SystemTime` is used instead of `Instant` for +timeout enforcement. This should be enabled if you don't desire to use in-memory collections to store the instances +of those types. Note even when written to persistent storage, an application should still periodically remove expired ceremonies. -If one is using a relational database (RDB); then one can achieve this by storing `ServerState::sent_challenge`, -the `Vec` returned from `Encode::encode`, and `ServerState::expiration` and periodically remove all rows +If one is using a relational database (RDB); then one can achieve this by storing `SentChallenge`, +the `Vec` returned from `Encode::encode`, and `TimedCeremony::expiration` and periodically remove all rows whose expiration exceeds the current date and time. ## Registration and authentication @@ -435,13 +429,14 @@ It is for those reasons data like `RegistrationServerState` are not serializable use of in-memory collections (e.g., `FixedCapHashSet`). To better ensure OOM is not a concern, RPs should set reasonable timeouts. Since ceremonies can only be completed by moving data (e.g., `RegistrationServerState::verify`), ceremony completion is guaranteed to free up the memory used— -`RegistrationServerState` instances are only 48 bytes on `x86_64-unknown-linux-gnu` platforms. To avoid issues -related to incomplete ceremonies, RPs can periodically iterate the collection for expired ceremonies and remove -such data. Other techniques can be employed as well to mitigate OOM, but they are application specific and -out-of-scope. If this is undesirable, one can enable [`serializable_server_state`](#serializable_server_state) -so that `RegistrationServerState` and `AuthenticationServerState` implement `Encode` and `Decode`. Another -reason one may need to store this information persistently is for load-balancing purposes where the server that -started the ceremony is not guaranteed to be the server that finishes the ceremony. +`RegistrationServerState` instances are as small as 48 bytes on `x86_64-unknown-linux-gnu` platforms. To avoid +issues related to incomplete ceremonies, RPs can periodically iterate the collection for expired ceremonies and +remove such data. Other techniques can be employed as well to mitigate OOM, but they are application specific +and out-of-scope. If this is undesirable, one can enable [`serializable_server_state`](#serializable_server_state) +so that `RegistrationServerState`, `DiscoverableAuthenticationServerState`, and +`NonDiscoverableAuthenticationServerState` implement `Encode` and `Decode`. Another reason one may need to +store this information persistently is for load-balancing purposes where the server that started the ceremony is +not guaranteed to be the server that finishes the ceremony. ## Supported signature algorithms diff --git a/src/hash.rs b/src/hash.rs @@ -0,0 +1,153 @@ +#[cfg(doc)] +use super::{ + hash::hash_set::FixedCapHashSet, + request::{ + Challenge, + auth::{ + DiscoverableAuthenticationServerState, DiscoverableCredentialRequestOptions, + NonDiscoverableAuthenticationServerState, NonDiscoverableCredentialRequestOptions, + }, + register::{PublicKeyCredentialCreationOptions, RegistrationServerState}, + }, +}; +use core::hash::{BuildHasher, Hasher}; +pub use hashbrown; +/// Fixed-capacity hash map. +pub mod hash_map; +/// Fixed-capacity hash set. +pub mod hash_set; +/// [`Hasher`] whose `write_*` methods simply store up to 64 bits of the passed argument _as is_ overwriting +/// any previous state. +/// +/// This is designed to only be used indirectly via a hash map whose keys are randomly generated on the server +/// based on at least 64 bits—the size of the integer returned from [`Self::finish`]—of entropy. +/// This makes this `Hasher` usable (and ideal) in only the most niche circumstances. +/// +/// [`RegistrationServerState`], [`DiscoverableAuthenticationServerState`], and +/// [`NonDiscoverableAuthenticationServerState`] implement [`Hash`] by simply writing the +/// contained [`Challenge`]; thus when they are stored in a hashed collection (e.g., [`FixedCapHashSet`]), one can +/// optimize without fear by using this `Hasher` since `Challenge`s are immutable and can only ever be created on +/// the server via [`Challenge::new`] (and equivalently [`Challenge::default`]). `RegistrationServerState`, +/// `DiscoverableAuthenticationServerState`, and `NonDiscoverableAuthenticationServerState` are also immutable and +/// only constructable via [`PublicKeyCredentialCreationOptions::start_ceremony`], +/// [`DiscoverableCredentialRequestOptions::start_ceremony`], and +/// [`NonDiscoverableCredentialRequestOptions::start_ceremony`] respectively. Since `Challenge` is already based on +/// a random `u128`, other `Hasher`s will be slower and likely produce lower-quality hashes (and never +/// higher quality). +#[derive(Debug)] +pub struct IdentityHasher(u64); +// Note it is _not_ required for `write_*` methods to do the same thing as other `write_*` methods +// (e.g., `Self::write_u64` may not be the same thing as 8 calls to `Self::write_u8`). +impl Hasher for IdentityHasher { + /// Returns `0` if no `write_*` calls have been made; otherwise returns the result of the most recent + /// `write_*` call. + #[inline] + fn finish(&self) -> u64 { + self.0 + } + /// Writes `i` to `self`. + #[inline] + fn write_u64(&mut self, i: u64) { + self.0 = i; + } + /// Sign-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "we simply need to convert into a u64 in a deterministic way" + )] + #[inline] + fn write_i8(&mut self, i: i8) { + self.write_u64(i as u64); + } + /// Sign-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "we simply need to convert into a u64 in a deterministic way" + )] + #[inline] + fn write_i16(&mut self, i: i16) { + self.write_u64(i as u64); + } + /// Sign-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "we simply need to convert into a u64 in a deterministic way" + )] + #[inline] + fn write_i32(&mut self, i: i32) { + self.write_u64(i as u64); + } + /// Redirects to [`Self::write_u64`]. + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "we simply need to convert into a u64 in a deterministic way" + )] + #[inline] + fn write_i64(&mut self, i: i64) { + self.write_u64(i as u64); + } + /// Truncates `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "we simply need to convert into a u64 in a deterministic way" + )] + #[inline] + fn write_i128(&mut self, i: i128) { + self.write_u64(i as u64); + } + /// Zero-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[inline] + fn write_u8(&mut self, i: u8) { + self.write_u64(u64::from(i)); + } + /// Zero-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[inline] + fn write_u16(&mut self, i: u16) { + self.write_u64(u64::from(i)); + } + /// Zero-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[inline] + fn write_u32(&mut self, i: u32) { + self.write_u64(u64::from(i)); + } + /// Truncates `i` to a [`u64`] before redirecting to [`Self::write_u64`]. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "we simply need to convert into a u64 in a deterministic way" + )] + #[inline] + fn write_u128(&mut self, i: u128) { + self.write_u64(i as u64); + } + /// This does nothing iff `bytes.len() < 8`; otherwise the first 8 bytes are converted + /// to a [`u64`] that is written via [`Self::write_u64`]; + #[expect(clippy::host_endian_bytes, reason = "endianness does not matter")] + #[inline] + fn write(&mut self, bytes: &[u8]) { + if let Some(data) = bytes.get(..8) { + let mut val = [0; 8]; + val.copy_from_slice(data); + self.write_u64(u64::from_ne_bytes(val)); + } + } +} +/// [`BuildHasher`] of an [`IdentityHasher`]. +/// +/// This MUST only be used with hash maps with keys that are randomly generated on the server based on at least 64 +/// bits of entropy. +#[derive(Clone, Copy, Debug)] +pub struct BuildIdentityHasher; +impl BuildHasher for BuildIdentityHasher { + type Hasher = IdentityHasher; + #[inline] + fn build_hasher(&self) -> Self::Hasher { + IdentityHasher(0) + } +} diff --git a/src/hash/hash_map.rs b/src/hash/hash_map.rs @@ -0,0 +1,364 @@ +use super::{super::request::TimedCeremony, BuildIdentityHasher}; +#[cfg(doc)] +use core::hash::Hasher; +use core::hash::{BuildHasher, Hash}; +use hashbrown::{ + Equivalent, + hash_map::{Drain, Entry, EntryRef, ExtractIf, HashMap, IterMut, OccupiedError, ValuesMut}, +}; +#[cfg(not(feature = "serializable_server_state"))] +use std::time::Instant; +#[cfg(feature = "serializable_server_state")] +use std::time::SystemTime; +/// `newtype` around [`HashMap`] that has maximum [`HashMap::capacity`]. +/// +/// This is useful in situations when the underlying entries are expected to be removed, and one wants to ensure the +/// map does not grow unbounded. When `K` is a [`TimedCeremony`], helper methods (e.g., +/// [`Self::insert_remove_all_expired`]) are provided that will automatically remove expired entries. Note the +/// intended use case is for `K` to be based on a server-side randomly generated value; thus the default [`Hasher`] +/// is [`BuildIdentityHasher`]. In the event this is not true, one MUST use a more appropriate `Hasher`. +/// +/// Only the mutable methods of `HashMap` are re-defined in order to ensure the capacity never grows. For all +/// other methods, first call [`Self::as_ref`] or [`Self::into`]. +/// +/// [`Self::into`]: struct.FixedCapHashMap.html#impl-Into<U>-for-T +#[derive(Debug)] +pub struct FixedCapHashMap<K, V, S = BuildIdentityHasher>(HashMap<K, V, S>); +impl<K, V> FixedCapHashMap<K, V, BuildIdentityHasher> { + /// [`HashMap::with_capacity_and_hasher`] using `capacity` and `BuildIdentityHasher`. + #[inline] + #[must_use] + pub fn new(capacity: usize) -> Self { + Self(HashMap::with_capacity_and_hasher( + capacity, + BuildIdentityHasher, + )) + } +} +impl<K, V, S> FixedCapHashMap<K, V, S> { + /// [`HashMap::values_mut`]. + #[inline] + pub fn values_mut(&mut self) -> ValuesMut<'_, K, V> { + self.0.values_mut() + } + /// [`HashMap::iter_mut`]. + #[expect( + clippy::iter_without_into_iter, + reason = "re-export all mutable methods of HashMap" + )] + #[inline] + pub fn iter_mut(&mut self) -> IterMut<'_, K, V> { + self.0.iter_mut() + } + /// [`HashMap::clear`]. + #[inline] + pub fn clear(&mut self) { + self.0.clear(); + } + /// [`HashMap::drain`]. + #[inline] + pub fn drain(&mut self) -> Drain<'_, K, V> { + self.0.drain() + } + /// [`HashMap::extract_if`]. + #[inline] + pub fn extract_if<F: FnMut(&K, &mut V) -> bool>(&mut self, f: F) -> ExtractIf<'_, K, V, F> { + self.0.extract_if(f) + } + /// [`HashMap::with_capacity_and_hasher`]. + #[inline] + #[must_use] + pub fn with_hasher(capacity: usize, hasher: S) -> Self { + Self(HashMap::with_capacity_and_hasher(capacity, hasher)) + } + /// [`HashMap::retain`]. + #[inline] + pub fn retain<F: FnMut(&K, &mut V) -> bool>(&mut self, f: F) { + self.0.retain(f); + } +} +impl<K: TimedCeremony, V, S> FixedCapHashMap<K, V, S> { + /// Removes all expired ceremonies. + #[inline] + pub fn remove_expired_ceremonies(&mut self) { + // Even though it's more accurate to check the current `Instant` for each ceremony, we elect to capture + // the `Instant` we begin iteration for performance reasons. It's unlikely an appreciable amount of + // additional ceremonies would be removed. + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + self.retain(|v, _| v.expiration() >= now); + } +} +impl<K: Eq + Hash, V, S: BuildHasher> FixedCapHashMap<K, V, S> { + /// [`HashMap::get_mut`]. + #[inline] + pub fn get_mut<Q: Equivalent<K> + Hash + ?Sized>(&mut self, k: &Q) -> Option<&mut V> { + self.0.get_mut(k) + } + /// [`HashMap::get_key_value_mut`]. + #[inline] + pub fn get_key_value_mut<Q: Equivalent<K> + Hash + ?Sized>( + &mut self, + k: &Q, + ) -> Option<(&K, &mut V)> { + self.0.get_key_value_mut(k) + } + /// [`HashMap::get_many_mut`]. + #[inline] + pub fn get_many_mut<Q: Equivalent<K> + Hash + ?Sized, const N: usize>( + &mut self, + ks: [&Q; N], + ) -> [Option<&mut V>; N] { + self.0.get_many_mut(ks) + } + /// [`HashMap::get_many_key_value_mut`]. + #[inline] + pub fn get_many_key_value_mut<Q: Equivalent<K> + Hash + ?Sized, const N: usize>( + &mut self, + ks: [&Q; N], + ) -> [Option<(&K, &mut V)>; N] { + self.0.get_many_key_value_mut(ks) + } + /// [`HashMap::remove`]. + #[inline] + pub fn remove<Q: Equivalent<K> + Hash + ?Sized>(&mut self, k: &Q) -> Option<V> { + self.0.remove(k) + } + /// [`HashMap::remove_entry`]. + #[inline] + pub fn remove_entry<Q: Equivalent<K> + Hash + ?Sized>(&mut self, k: &Q) -> Option<(K, V)> { + self.0.remove_entry(k) + } + /// [`HashMap::try_insert`]. + /// + /// `Ok(None)` is returned iff [`HashMap::len`] `==` [`HashMap::capacity`] and `key` does not already exist in + /// the map. + /// + /// # Errors + /// + /// Errors iff [`HashMap::insert`] does. + #[inline] + pub fn try_insert( + &mut self, + key: K, + value: V, + ) -> Result<Option<&mut V>, OccupiedError<'_, K, V, S>> { + let full = self.0.len() == self.0.capacity(); + match self.0.entry(key) { + Entry::Occupied(entry) => Err(OccupiedError { entry, value }), + Entry::Vacant(ent) => { + if full { + Ok(None) + } else { + Ok(Some(ent.insert(value))) + } + } + } + } + /// [`HashMap::insert`]. + /// + /// `None` is returned iff [`HashMap::len`] `==` [`HashMap::capacity`] and `key` does not already exist in the + /// map. + #[inline] + pub fn insert(&mut self, k: K, v: V) -> Option<Option<V>> { + let full = self.0.len() == self.0.capacity(); + match self.0.entry(k) { + Entry::Occupied(mut ent) => Some(Some(ent.insert(v))), + Entry::Vacant(ent) => { + if full { + None + } else { + ent.insert(v); + Some(None) + } + } + } + } + /// [`HashMap::entry`]. + /// + /// `None` is returned iff [`HashMap::len`] `==` [`HashMap::capacity`] and `key` does not already exist in the + /// map. + #[inline] + pub fn entry(&mut self, key: K) -> Option<Entry<'_, K, V, S>> { + let full = self.0.len() == self.0.capacity(); + match self.0.entry(key) { + ent @ Entry::Occupied(_) => Some(ent), + ent @ Entry::Vacant(_) => { + if full { + None + } else { + Some(ent) + } + } + } + } + /// [`HashMap::entry_ref`]. + /// + /// `None` is returned iff [`HashMap::len`] `==` [`HashMap::capacity`] and `key` does not already exist in the + /// map. + #[inline] + pub fn entry_ref<'a, 'b, Q: Equivalent<K> + Hash + ?Sized>( + &'a mut self, + key: &'b Q, + ) -> Option<EntryRef<'a, 'b, K, Q, V, S>> { + let full = self.0.len() == self.0.capacity(); + match self.0.entry_ref(key) { + ent @ EntryRef::Occupied(_) => Some(ent), + ent @ EntryRef::Vacant(_) => { + if full { + None + } else { + Some(ent) + } + } + } + } +} +impl<K: Eq + Hash + TimedCeremony, V, S: BuildHasher> FixedCapHashMap<K, V, S> { + /// [`Self::insert`] except the first expired ceremony is removed in the event there is no available capacity. + #[inline] + pub fn insert_remove_expired(&mut self, k: K, v: V) -> Option<Option<V>> { + if self.0.len() == self.0.capacity() { + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + if self + .0 + .extract_if(|exp, _| exp.expiration() < now) + .next() + .is_some() + { + Some(self.0.insert(k, v)) + } else if let Entry::Occupied(mut ent) = self.0.entry(k) { + Some(Some(ent.insert(v))) + } else { + None + } + } else { + Some(self.0.insert(k, v)) + } + } + /// [`Self::insert`] except all expired ceremones are removed in the event there is no available capacity. + #[inline] + pub fn insert_remove_all_expired(&mut self, k: K, v: V) -> Option<Option<V>> { + if self.0.len() == self.0.capacity() { + self.remove_expired_ceremonies(); + } + if self.0.len() == self.0.capacity() { + if let Entry::Occupied(mut ent) = self.0.entry(k) { + Some(Some(ent.insert(v))) + } else { + None + } + } else { + Some(self.0.insert(k, v)) + } + } + /// [`Self::entry`] except the first expired ceremony is removed in the event there is no available capacity. + #[inline] + pub fn entry_remove_expired(&mut self, key: K) -> Option<Entry<'_, K, V, S>> { + if self.0.len() == self.0.capacity() { + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + if self + .0 + .extract_if(|v, _| v.expiration() < now) + .next() + .is_some() + { + Some(self.0.entry(key)) + } else if let ent @ Entry::Occupied(_) = self.0.entry(key) { + Some(ent) + } else { + None + } + } else { + Some(self.0.entry(key)) + } + } + /// [`Self::entry`] except all expired ceremones are removed in the event there is no available capacity. + #[inline] + pub fn entry_remove_all_expired(&mut self, key: K) -> Option<Entry<'_, K, V, S>> { + if self.0.len() == self.0.capacity() { + self.remove_expired_ceremonies(); + } + if self.0.len() == self.0.capacity() { + if let ent @ Entry::Occupied(_) = self.0.entry(key) { + Some(ent) + } else { + None + } + } else { + Some(self.0.entry(key)) + } + } + /// [`Self::entry_ref`] except the first expired ceremony is removed in the event there is no available capacity. + #[inline] + pub fn entry_ref_remove_expired<'a, 'b, Q: Equivalent<K> + Hash + ?Sized>( + &'a mut self, + key: &'b Q, + ) -> Option<EntryRef<'a, 'b, K, Q, V, S>> { + if self.0.len() == self.0.capacity() { + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + if self + .0 + .extract_if(|v, _| v.expiration() < now) + .next() + .is_some() + { + Some(self.0.entry_ref(key)) + } else if let ent @ EntryRef::Occupied(_) = self.0.entry_ref(key) { + Some(ent) + } else { + None + } + } else { + Some(self.0.entry_ref(key)) + } + } + /// [`Self::entry_ref`] except all expired ceremones are removed in the event there is no available capacity. + #[inline] + pub fn entry_ref_remove_all_expired<'a, 'b, Q: Equivalent<K> + Hash + ?Sized>( + &'a mut self, + key: &'b Q, + ) -> Option<EntryRef<'a, 'b, K, Q, V, S>> { + if self.0.len() == self.0.capacity() { + self.remove_expired_ceremonies(); + } + if self.0.len() == self.0.capacity() { + if let ent @ EntryRef::Occupied(_) = self.0.entry_ref(key) { + Some(ent) + } else { + None + } + } else { + Some(self.0.entry_ref(key)) + } + } +} +impl<K, V, S> AsRef<HashMap<K, V, S>> for FixedCapHashMap<K, V, S> { + #[inline] + fn as_ref(&self) -> &HashMap<K, V, S> { + &self.0 + } +} +impl<K, V, S> From<FixedCapHashMap<K, V, S>> for HashMap<K, V, S> { + #[inline] + fn from(value: FixedCapHashMap<K, V, S>) -> Self { + value.0 + } +} +impl<K, V, S> From<HashMap<K, V, S>> for FixedCapHashMap<K, V, S> { + #[inline] + fn from(value: HashMap<K, V, S>) -> Self { + Self(value) + } +} diff --git a/src/hash/hash_set.rs b/src/hash/hash_set.rs @@ -0,0 +1,256 @@ +use super::{super::request::TimedCeremony, BuildIdentityHasher}; +#[cfg(doc)] +use core::hash::Hasher; +use core::hash::{BuildHasher, Hash}; +use hashbrown::{ + Equivalent, + hash_set::{Drain, Entry, ExtractIf, HashSet}, +}; +#[cfg(not(feature = "serializable_server_state"))] +use std::time::Instant; +#[cfg(feature = "serializable_server_state")] +use std::time::SystemTime; +/// `newtype` around [`HashSet`] that 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., +/// [`Self::insert_remove_all_expired`]) are provided that will automatically remove expired values. Note the +/// intended use case is for `T` to be based on a server-side randomly generated value; thus the default [`Hasher`] +/// is [`BuildIdentityHasher`]. In the event this is not true, one MUST use a more appropriate `Hasher`. +/// +/// Only the mutable methods of `HashSet` are re-defined in order to ensure the capacity never grows. For all +/// other methods, first call [`Self::as_ref`] or [`Self::into`]. +/// +/// [`Self::into`]: struct.FixedCapHashSet.html#impl-Into<U>-for-T +#[derive(Debug)] +pub struct FixedCapHashSet<T, S = BuildIdentityHasher>(HashSet<T, S>); +impl<T> FixedCapHashSet<T, BuildIdentityHasher> { + /// [`HashSet::with_capacity_and_hasher`] using `capacity` and `BuildIdentityHasher`. + #[inline] + #[must_use] + pub fn new(capacity: usize) -> Self { + Self(HashSet::with_capacity_and_hasher( + capacity, + BuildIdentityHasher, + )) + } +} +impl<T, S> FixedCapHashSet<T, S> { + /// [`HashSet::clear`]. + #[inline] + pub fn clear(&mut self) { + self.0.clear(); + } + /// [`HashSet::drain`]. + #[inline] + pub fn drain(&mut self) -> Drain<'_, T> { + self.0.drain() + } + /// [`HashSet::extract_if`]. + #[inline] + pub fn extract_if<F: FnMut(&T) -> bool>(&mut self, f: F) -> ExtractIf<'_, T, F> { + self.0.extract_if(f) + } + /// [`HashSet::with_capacity_and_hasher`]. + #[inline] + #[must_use] + pub fn with_hasher(capacity: usize, hasher: S) -> Self { + Self(HashSet::with_capacity_and_hasher(capacity, hasher)) + } + /// [`HashSet::retain`]. + #[inline] + pub fn retain<F: FnMut(&T) -> bool>(&mut self, f: F) { + self.0.retain(f); + } +} +impl<T: TimedCeremony, S> FixedCapHashSet<T, S> { + /// Removes all expired ceremonies. + #[inline] + pub fn remove_expired_ceremonies(&mut self) { + // Even though it's more accurate to check the current `Instant` for each ceremony, we elect to capture + // the `Instant` we begin iteration for performance reasons. It's unlikely an appreciable amount of + // additional ceremonies would be removed. + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + self.retain(|v| v.expiration() >= now); + } +} +impl<T: Eq + Hash, S: BuildHasher> FixedCapHashSet<T, S> { + /// [`HashSet::get_or_insert`]. + /// + /// `None` is returned iff [`HashSet::len`] `==` [`HashSet::capacity`] and `value` does not already exist in the + /// set. + #[inline] + pub fn get_or_insert(&mut self, value: T) -> Option<&T> { + if self.0.len() == self.0.capacity() { + self.0.get(&value) + } else { + Some(self.0.get_or_insert(value)) + } + } + /// [`HashSet::get_or_insert_with`]. + /// + /// `None` is returned iff [`HashSet::len`] `==` [`HashSet::capacity`] and `value` does not already exist in the + /// set. + #[inline] + pub fn get_or_insert_with<Q: Equivalent<T> + Hash + ?Sized, F: FnOnce(&Q) -> T>( + &mut self, + value: &Q, + f: F, + ) -> Option<&T> { + if self.0.len() == self.0.capacity() { + self.0.get(value) + } else { + Some(self.0.get_or_insert_with(value, f)) + } + } + /// [`HashSet::remove`]. + #[inline] + pub fn remove<Q: Equivalent<T> + Hash + ?Sized>(&mut self, value: &Q) -> bool { + self.0.remove(value) + } + /// [`HashSet::take`]. + #[inline] + pub fn take<Q: Equivalent<T> + Hash + ?Sized>(&mut self, value: &Q) -> Option<T> { + self.0.take(value) + } + /// [`HashSet::insert`]. + /// + /// `None` is returned iff [`HashSet::len`] `==` [`HashSet::capacity`] and `value` does not already exist in the + /// set. + #[inline] + pub fn insert(&mut self, value: T) -> Option<bool> { + let full = self.0.len() == self.0.capacity(); + if let Entry::Vacant(ent) = self.0.entry(value) { + if full { + None + } else { + ent.insert(); + Some(true) + } + } else { + Some(false) + } + } + /// [`HashSet::replace`]. + /// + /// `None` is returned iff [`HashSet::len`] `==` [`HashSet::capacity`] and `value` does not already exist in the + /// set. + #[inline] + pub fn replace(&mut self, value: T) -> Option<Option<T>> { + // Ideally we would use the Entry API to avoid searching multiple times, but one can't while also using + // `replace` since there is no `OccupiedEntry::replace`. + if self.0.contains(&value) { + Some(self.0.replace(value)) + } else if self.0.len() == self.0.capacity() { + None + } else { + self.0.insert(value); + Some(None) + } + } + /// [`HashSet::entry`]. + /// + /// `None` is returned iff [`HashSet::len`] `==` [`HashSet::capacity`] and `value` does not already exist in the + /// set. + #[inline] + pub fn entry(&mut self, value: T) -> Option<Entry<'_, T, S>> { + let full = self.0.len() == self.0.capacity(); + match self.0.entry(value) { + ent @ Entry::Occupied(_) => Some(ent), + ent @ Entry::Vacant(_) => { + if full { + None + } else { + Some(ent) + } + } + } + } +} +impl<T: Eq + Hash + TimedCeremony, S: BuildHasher> FixedCapHashSet<T, S> { + /// [`Self::insert`] except the first expired ceremony is removed in the event there is no available capacity. + #[inline] + pub fn insert_remove_expired(&mut self, value: T) -> Option<bool> { + if self.0.len() == self.0.capacity() { + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + if self.0.extract_if(|v| v.expiration() < now).next().is_some() { + Some(self.0.insert(value)) + } else { + self.0.contains(&value).then_some(false) + } + } else { + Some(self.0.insert(value)) + } + } + /// [`Self::insert`] except all expired ceremones are removed in the event there is no available capacity. + #[inline] + pub fn insert_remove_all_expired(&mut self, value: T) -> Option<bool> { + if self.0.len() == self.0.capacity() { + self.remove_expired_ceremonies(); + } + if self.0.len() == self.0.capacity() { + self.0.contains(&value).then_some(false) + } else { + Some(self.0.insert(value)) + } + } + /// [`Self::entry`] except the first expired ceremony is removed in the event there is no available capacity. + #[inline] + pub fn entry_remove_expired(&mut self, value: T) -> Option<Entry<'_, T, S>> { + if self.0.len() == self.0.capacity() { + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + if self.0.extract_if(|v| v.expiration() < now).next().is_some() { + Some(self.0.entry(value)) + } else if let ent @ Entry::Occupied(_) = self.0.entry(value) { + Some(ent) + } else { + None + } + } else { + Some(self.0.entry(value)) + } + } + /// [`Self::entry`] except all expired ceremones are removed in the event there is no available capacity. + #[inline] + pub fn entry_remove_all_expired(&mut self, value: T) -> Option<Entry<'_, T, S>> { + if self.0.len() == self.0.capacity() { + self.remove_expired_ceremonies(); + } + if self.0.len() == self.0.capacity() { + if let ent @ Entry::Occupied(_) = self.0.entry(value) { + Some(ent) + } else { + None + } + } else { + Some(self.0.entry(value)) + } + } +} +impl<T, S> AsRef<HashSet<T, S>> for FixedCapHashSet<T, S> { + #[inline] + fn as_ref(&self) -> &HashSet<T, S> { + &self.0 + } +} +impl<T, S> From<FixedCapHashSet<T, S>> for HashSet<T, S> { + #[inline] + fn from(value: FixedCapHashSet<T, S>) -> Self { + value.0 + } +} +impl<T, S> From<HashSet<T, S>> for FixedCapHashSet<T, S> { + #[inline] + fn from(value: HashSet<T, S>) -> Self { + Self(value) + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -17,16 +17,18 @@ //! ## `webauthn_rp` in action //! //! ```no_run +//! use core::convert; //! use webauthn_rp::{ -//! AuthenticatedCredential, DiscoverableAuthentication64, DiscoverableAuthenticationServerState, -//! DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions, RegisteredCredential, -//! Registration, RegistrationServerState, +//! AuthenticatedCredential64, DiscoverableAuthentication64, DiscoverableAuthenticationServerState, +//! DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions64, RegisteredCredential64, +//! Registration, RegistrationServerState64, +//! hash::hash_set::FixedCapHashSet, //! request::{ //! AsciiDomain, PublicKeyCredentialDescriptor, RpId, //! auth::AuthenticationVerificationOptions, //! register::{ -//! Nickname, PublicKeyCredentialUserEntity, RegistrationVerificationOptions, -//! USER_HANDLE_MAX_LEN, UserHandle64, Username, +//! Nickname, PublicKeyCredentialUserEntity64, RegistrationVerificationOptions, +//! UserHandle64, Username, //! }, //! }, //! response::{ @@ -35,9 +37,6 @@ //! register::{CompressedPubKey, DynamicState, error::RegCeremonyErr}, //! }, //! }; -//! # #[cfg(not(feature = "serializable_server_state"))] -//! // These are available iff `serializable_server_state` is _not_ enabled. -//! use webauthn_rp::request::{FixedCapHashSet, InsertResult}; //! # #[cfg(feature = "serde")] //! use serde::de::{Deserialize, Deserializer}; //! # #[cfg(feature = "serde_relaxed")] @@ -105,9 +104,9 @@ //! /// This only makes sense for greenfield deployments since account information (e.g., user name) would likely //! /// already exist otherwise. This is similar to credential creation except a random `UserHandle64` is generated and //! /// will be used for subsequent credential registrations. -//! # #[cfg(all(feature = "serde_relaxed", not(feature = "serializable_server_state")))] +//! # #[cfg(feature = "serde_relaxed")] //! fn start_account_creation( -//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, +//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, //! ) -> Result<Vec<u8>, AppErr> { //! let rp_id = RpId::Domain( //! AsciiDomain::try_from(RP_ID.to_owned()) @@ -115,19 +114,17 @@ //! ); //! let user_id = UserHandle64::new(); //! let (server, client) = -//! PublicKeyCredentialCreationOptions::first_passkey_with_blank_user_info( +//! PublicKeyCredentialCreationOptions64::first_passkey_with_blank_user_info( //! &rp_id, &user_id, //! ) //! .start_ceremony() //! .unwrap_or_else(|_e| { -//! unreachable!("we don't manually mutate the options; thus this can't fail") +//! unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") //! }); -//! if matches!( -//! reg_ceremonies.insert_or_replace_all_expired(server), -//! InsertResult::Success -//! ) { +//! if reg_ceremonies.insert_remove_all_expired(server).is_some_and(convert::identity) +//! { //! Ok(serde_json::to_vec(&client) -//! .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState::serialize"))) +//! .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState64::serialize"))) //! } else { //! Err(AppErr::WebAuthnCeremonyCreation) //! } @@ -140,9 +137,9 @@ //! /// //! /// Note if this errors, then the user should be notified to delete the passkey created on their //! /// authenticator. -//! # #[cfg(all(feature = "serde_relaxed", not(feature = "serializable_server_state")))] +//! # #[cfg(feature = "serde_relaxed")] //! fn finish_account_creation( -//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, +//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, //! client_data: Vec<u8>, //! ) -> Result<(), AppErr> { //! let account = serde_json::from_slice::<AccountReg<'_, '_>>(client_data.as_slice())?; @@ -168,27 +165,25 @@ //! /// passkey. This is similar to account creation except we already have the user entity info and we need to //! /// fetch the registered `PublicKeyCredentialDescriptor`s to avoid accidentally overwriting a passkey on //! /// the authenticator. -//! # #[cfg(all(feature = "serde_relaxed", not(feature = "serializable_server_state")))] +//! # #[cfg(feature = "serde_relaxed")] //! fn start_cred_registration( //! user_id: &UserHandle64, -//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, +//! 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) = PublicKeyCredentialCreationOptions::passkey(&rp_id, entity, creds) +//! let (server, client) = PublicKeyCredentialCreationOptions64::passkey(&rp_id, entity, creds) //! .start_ceremony() //! .unwrap_or_else(|_e| { -//! unreachable!("we don't manually mutate the options; thus this won't error") +//! unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") //! }); -//! if matches!( -//! reg_ceremonies.insert_or_replace_all_expired(server), -//! InsertResult::Success -//! ) { +//! if reg_ceremonies.insert_remove_all_expired(server).is_some_and(convert::identity) +//! { //! Ok(serde_json::to_vec(&client) -//! .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState::serialize"))) +//! .unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState64::serialize"))) //! } else { //! Err(AppErr::WebAuthnCeremonyCreation) //! } @@ -201,9 +196,9 @@ //! /// //! /// Note if this errors, then the user should be notified to delete the passkey created on their //! /// authenticator. -//! # #[cfg(all(feature = "serde_relaxed", not(feature = "serializable_server_state")))] +//! # #[cfg(feature = "serde_relaxed")] //! fn finish_cred_registration( -//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState<USER_HANDLE_MAX_LEN>>, +//! reg_ceremonies: &mut FixedCapHashSet<RegistrationServerState64>, //! client_data: Vec<u8>, //! ) -> Result<(), AppErr> { //! // `Registration::from_json_custom` is available iff `serde_relaxed` is enabled. @@ -224,7 +219,7 @@ //! ) //! } //! /// Starts the passkey authentication ceremony. -//! # #[cfg(all(feature = "serde_relaxed", not(feature = "serializable_server_state")))] +//! # #[cfg(feature = "serde_relaxed")] //! fn start_auth( //! auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, //! ) -> Result<Vec<u8>, AppErr> { @@ -235,12 +230,10 @@ //! let (server, client) = DiscoverableCredentialRequestOptions::passkey(&rp_id) //! .start_ceremony() //! .unwrap_or_else(|_e| { -//! unreachable!("we don't manually mutate the options; thus this won't error") +//! unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") //! }); -//! if matches!( -//! auth_ceremonies.insert_or_replace_all_expired(server), -//! InsertResult::Success -//! ) { +//! if auth_ceremonies.insert_remove_all_expired(server).is_some_and(convert::identity) +//! { //! Ok(serde_json::to_vec(&client).unwrap_or_else(|_e| { //! unreachable!("bug in DiscoverableAuthenticationClientState::serialize") //! })) @@ -249,12 +242,12 @@ //! } //! } //! /// Finishes the passkey authentication ceremony. -//! # #[cfg(all(feature = "serde_relaxed", not(feature = "serializable_server_state")))] +//! # #[cfg(feature = "serde_relaxed")] //! fn finish_auth( //! auth_ceremonies: &mut FixedCapHashSet<DiscoverableAuthenticationServerState>, //! client_data: Vec<u8>, //! ) -> Result<(), AppErr> { -//! // `Authentication::from_json_custom` is available iff `serde_relaxed` is enabled. +//! // `DiscoverableAuthentication64::from_json_custom` is available iff `serde_relaxed` is enabled. //! let authentication = //! DiscoverableAuthentication64::from_json_custom(client_data.as_slice())?; //! let mut cred = select_credential( @@ -263,7 +256,7 @@ //! )? //! .ok_or_else(|| AppErr::NoCredential)?; //! if auth_ceremonies -//! // `Authentication::challenge_relaxed` is available iff `serde_relaxed` is enabled. +//! // `DiscoverableAuthentication64::challenge_relaxed` is available iff `serde_relaxed` is enabled. //! .take(&authentication.challenge_relaxed()?) //! .ok_or(AppErr::MissingWebAuthnCeremony)? //! .verify( @@ -289,7 +282,7 @@ //! /// `CredentialId`, or there already exists an account using the same `UserHandle64`. //! fn insert_account( //! account: &AccountReg<'_, '_>, -//! cred: RegisteredCredential<'_, USER_HANDLE_MAX_LEN>, +//! cred: RegisteredCredential64<'_>, //! ) -> Result<(), AppErr> { //! // ⋮ //! # Ok(()) @@ -303,7 +296,7 @@ //! user_id: &'a UserHandle64, //! ) -> Result< //! Option<( -//! PublicKeyCredentialUserEntity<'static, 'static, 'a, USER_HANDLE_MAX_LEN>, +//! PublicKeyCredentialUserEntity64<'static, 'static, 'a>, //! Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, //! )>, //! AppErr, @@ -315,9 +308,10 @@ //! /// //! /// # Errors //! /// -//! /// Errors iff writing `cred` errors or there already exists a credential using the same `CredentialId`. +//! /// Errors iff writing `cred` errors, there already exists a credential using the same `CredentialId`, +//! /// or there does not exist an account under the `UserHandle64`. //! fn insert_credential( -//! cred: RegisteredCredential<'_, USER_HANDLE_MAX_LEN>, +//! cred: RegisteredCredential64<'_>, //! ) -> Result<(), AppErr> { //! // ⋮ //! # Ok(()) @@ -333,10 +327,9 @@ //! user_id: &'user UserHandle64, //! ) -> Result< //! Option< -//! AuthenticatedCredential< +//! AuthenticatedCredential64< //! 'cred, //! 'user, -//! USER_HANDLE_MAX_LEN, //! CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>, //! >, //! >, @@ -430,7 +423,7 @@ //! of those types. //! //! Note even when written to persistent storage, an application should still periodically remove expired ceremonies. -//! If one is using a relational database (RDB); then one can achieve this by storing [`TimedCeremony::sent_challenge`], +//! If one is using a relational database (RDB); then one can achieve this by storing [`SentChallenge`], //! the `Vec` returned from [`Encode::encode`], and [`TimedCeremony::expiration`] and periodically remove all rows //! whose expiration exceeds the current date and time. //! @@ -518,50 +511,6 @@ //! [^note]: `panic`s related to memory allocations or stack overflow are possible since such issues are not //! formally guarded against. #![cfg_attr(docsrs, feature(doc_cfg))] -#![deny( - unknown_lints, - future_incompatible, - let_underscore, - missing_docs, - nonstandard_style, - refining_impl_trait, - rust_2018_compatibility, - rust_2018_idioms, - rust_2021_compatibility, - rust_2024_compatibility, - unsafe_code, - unused, - warnings, - clippy::all, - clippy::cargo, - clippy::complexity, - clippy::correctness, - clippy::nursery, - clippy::pedantic, - clippy::perf, - clippy::restriction, - clippy::style, - clippy::suspicious -)] -#![expect( - clippy::arbitrary_source_item_ordering, - clippy::blanket_clippy_restriction_lints, - clippy::exhaustive_enums, - clippy::exhaustive_structs, - clippy::implicit_return, - clippy::min_ident_chars, - clippy::missing_trait_methods, - clippy::multiple_crate_versions, - clippy::pub_with_shorthand, - clippy::pub_use, - clippy::ref_patterns, - clippy::return_and_then, - clippy::self_named_module_files, - clippy::single_call_fn, - clippy::single_char_lifetime_names, - clippy::unseparated_literal_suffix, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] #[cfg(not(any(feature = "custom", all(feature = "bin", feature = "serde"))))] compile_error!("'custom' must be enabled or both 'bin' and 'serde' must be enabled"); #[cfg(feature = "serializable_server_state")] @@ -577,24 +526,20 @@ use crate::request::{ use crate::response::error::CredentialIdErr; #[cfg(feature = "serde_relaxed")] use crate::response::ser_relaxed::SerdeJsonErr; -#[cfg(feature = "bin")] -use crate::{ - request::register::bin::{DecodeNicknameErr, DecodeUsernameErr}, - response::{ - bin::DecodeAuthTransportsErr, - register::bin::{DecodeDynamicStateErr, DecodeStaticStateErr}, - }, -}; #[cfg(doc)] use crate::{ + hash::hash_set::FixedCapHashSet, request::{ - AsciiDomain, DomainOrigin, FixedCapHashSet, Port, PublicKeyCredentialDescriptor, RpId, - Scheme, TimedCeremony, Url, + AsciiDomain, DomainOrigin, Port, PublicKeyCredentialDescriptor, RpId, Scheme, + TimedCeremony, Url, auth::{AllowedCredential, AllowedCredentials, PublicKeyCredentialRequestOptions}, - register::{CoseAlgorithmIdentifier, Nickname, PublicKeyCredentialUserEntity, Username}, + register::{ + CoseAlgorithmIdentifier, Nickname, PublicKeyCredentialUserEntity, UserHandle16, + UserHandle64, Username, + }, }, response::{ - CollectedClientData, Flag, + CollectedClientData, Flag, SentChallenge, auth::{self, Authentication, DiscoverableAuthenticatorAssertion}, register::{ self, Aaguid, Attestation, AttestationObject, AttestedCredentialData, @@ -603,12 +548,20 @@ use crate::{ }, }, }; +#[cfg(feature = "bin")] +use crate::{ + request::register::bin::{DecodeNicknameErr, DecodeUsernameErr}, + response::{ + bin::DecodeAuthTransportsErr, + register::bin::{DecodeDynamicStateErr, DecodeStaticStateErr}, + }, +}; use crate::{ request::{ auth::error::{RequestOptionsErr, SecondFactorErr}, error::{AsciiDomainErr, DomainOriginParseErr, PortParseErr, SchemeParseErr, UrlErr}, register::{ - ResidentKeyRequirement, UserHandle, + ResidentKeyRequirement, USER_HANDLE_MAX_LEN, UserHandle, error::{CreationOptionsErr, NicknameErr, UsernameErr}, }, }, @@ -633,6 +586,7 @@ use core::{ convert, error::Error, fmt::{self, Display, Formatter}, + ops::Not, }; #[cfg(all(doc, feature = "serde_relaxed"))] use response::register::ser_relaxed::RegistrationRelaxed; @@ -648,6 +602,8 @@ use std::time::{Instant, SystemTime}; #[cfg_attr(docsrs, doc(cfg(feature = "bin")))] #[cfg(feature = "bin")] pub mod bin; +/// Contains functionality for fixed-capacity hash maps and sets. +pub mod hash; /// Functionality for starting ceremonies. /// /// # What kind of credential should I create? @@ -719,7 +675,10 @@ pub use crate::{ NonDiscoverableAuthenticationServerState, NonDiscoverableCredentialRequestOptions, }, register::{ - PublicKeyCredentialCreationOptions, RegistrationClientState, RegistrationServerState, + PublicKeyCredentialCreationOptions, PublicKeyCredentialCreationOptions16, + PublicKeyCredentialCreationOptions64, RegistrationClientState, + RegistrationClientState16, RegistrationClientState64, RegistrationServerState, + RegistrationServerState16, RegistrationServerState64, }, }, response::{ @@ -731,22 +690,24 @@ pub use crate::{ register::Registration, }, }; -/// Error returned in [`RegCeremonyErr::Credential`] and [`AuthCeremonyErr::Credential`] as well as -/// from [`AuthenticatedCredential::new`]. +/// Error returned in [`RegCeremonyErr::Credential`] and [`AuthenticatedCredential::new`]. #[derive(Clone, Copy, Debug)] pub enum CredentialErr { /// Variant when [`CredentialProtectionPolicy::UserVerificationRequired`], but /// [`DynamicState::user_verified`] is `false`. CredProtectUserVerificationRequiredWithoutUserVerified, - /// Variant when [`AuthenticatorExtensionOutput::hmac_secret`] is `Some(true)`, but + /// Variant when [`ClientExtensionsOutputs::prf`] is + /// `Some(AuthenticationExtensionsPRFOutputs { enabled: true })` and /// [`DynamicState::user_verified`] is `false`. - HmacSecretWithoutUserVerified, + PrfWithoutUserVerified, /// Variant when [`AuthenticatorExtensionOutput::hmac_secret`] is `Some(true)`, but - /// [`ClientExtensionsOutputs::prf`] is not `Some(AuthenticationExtensionsPRFOutputs { enabled: true })`. + /// [`ClientExtensionsOutputs::prf`] is `Some(AuthenticationExtensionsPRFOutputs { enabled: false })` + /// or `AuthenticatorExtensionOutput::hmac_secret` is `Some`, but + /// `ClientExtensionsOutputs::prf` is `None`. HmacSecretWithoutPrf, /// Variant when [`ClientExtensionsOutputs::prf`] is /// `Some(AuthenticationExtensionsPRFOutputs { enabled: true })`, but - /// [`AuthenticatorExtensionOutput::hmac_secret`] is not `Some(true)`. + /// [`AuthenticatorExtensionOutput::hmac_secret`] is `Some(false)`. PrfWithoutHmacSecret, /// Variant when [`ResidentKeyRequirement::Required`] was sent, but /// [`CredentialPropertiesOutput::rk`] is `Some(false)`. @@ -759,9 +720,7 @@ impl Display for CredentialErr { Self::CredProtectUserVerificationRequiredWithoutUserVerified => { "credProtect requires user verification, but the user is not verified" } - Self::HmacSecretWithoutUserVerified => { - "hmac-secret is enabled, but the user is not verified" - } + Self::PrfWithoutUserVerified => "prf is enabled, but the user is not verified", Self::HmacSecretWithoutPrf => "hmac-secret was enabled but prf was not", Self::PrfWithoutHmacSecret => "prf was enabled, but hmac-secret was not", Self::ResidentKeyRequiredServerCredentialCreated => { @@ -771,11 +730,11 @@ impl Display for CredentialErr { } } impl Error for CredentialErr {} -/// Checks if the `static_state` and `dynamic_state` are valid for a credential. +/// Checks if the `static_state` and `dynamic_state` are valid. /// /// # Errors /// -/// Errors iff `static_state` or `dynamic_state` are invalid. +/// Errors iff `static_state` or `dynamc_state` are invalid. fn verify_static_and_dynamic_state<T>( static_state: &StaticState<T>, dynamic_state: DynamicState, @@ -787,11 +746,43 @@ fn verify_static_and_dynamic_state<T>( CredentialProtectionPolicy::UserVerificationRequired ) { Err(CredentialErr::CredProtectUserVerificationRequiredWithoutUserVerified) - } else if static_state.extensions.hmac_secret.unwrap_or_default() { - Err(CredentialErr::HmacSecretWithoutUserVerified) + } else if static_state + .client_extension_results + .prf + .is_some_and(|prf| prf.enabled) + { + Err(CredentialErr::PrfWithoutUserVerified) } else { Ok(()) } + .and_then(|()| { + static_state.client_extension_results.prf.map_or_else( + || { + if static_state.extensions.hmac_secret.is_none() { + Ok(()) + } else { + Err(CredentialErr::HmacSecretWithoutPrf) + } + }, + |prf| { + if prf.enabled { + if static_state.extensions.hmac_secret.is_some_and(Not::not) { + Err(CredentialErr::PrfWithoutHmacSecret) + } else { + Ok(()) + } + } else if static_state + .extensions + .hmac_secret + .is_some_and(convert::identity) + { + Err(CredentialErr::HmacSecretWithoutPrf) + } else { + Ok(()) + } + }, + ) + }) } /// Registered credential that needs to be saved server-side to perform future /// [authentication ceremonies](https://www.w3.org/TR/webauthn-3/#authentication-ceremony) with @@ -927,48 +918,24 @@ impl<'reg, const USER_LEN: usize> RegisteredCredential<'reg, USER_LEN> { metadata: Metadata<'a>, ) -> Result<Self, CredentialErr> { verify_static_and_dynamic_state(&static_state, dynamic_state).and_then(|()| { - // `verify_static_and_dynamic_state` already ensures that - // `hmac-secret` is not `Some(true)` when `!dynamic_state.user_verified`; - // thus we only need to check that one is not enabled without the other. - if static_state.extensions.hmac_secret.unwrap_or_default() { - if metadata + if !matches!(metadata.resident_key, ResidentKeyRequirement::Required) + || metadata .client_extension_results - .prf - .is_some_and(|prf| prf.enabled) - { - Ok(()) - } else { - Err(CredentialErr::HmacSecretWithoutPrf) - } - } else if metadata - .client_extension_results - .prf - .is_some_and(|prf| prf.enabled) + .cred_props + .as_ref() + .is_none_or(|props| props.rk.is_none_or(convert::identity)) { - Err(CredentialErr::PrfWithoutHmacSecret) + Ok(Self { + id, + transports, + user_id, + static_state, + dynamic_state, + metadata, + }) } else { - Ok(()) + Err(CredentialErr::ResidentKeyRequiredServerCredentialCreated) } - .and_then(|()| { - if !matches!(metadata.resident_key, ResidentKeyRequirement::Required) - || metadata - .client_extension_results - .cred_props - .as_ref() - .is_none_or(|props| props.rk.is_none_or(convert::identity)) - { - Ok(Self { - id, - transports, - user_id, - static_state, - dynamic_state, - metadata, - }) - } else { - Err(CredentialErr::ResidentKeyRequiredServerCredentialCreated) - } - }) }) } /// Returns the contained data consuming `self`. @@ -1016,6 +983,10 @@ impl<'reg, const USER_LEN: usize> RegisteredCredential<'reg, USER_LEN> { ) } } +/// `RegisteredCredential` based on a [`UserHandle64`]. +pub type RegisteredCredential64<'reg> = RegisteredCredential<'reg, USER_HANDLE_MAX_LEN>; +/// `RegisteredCredential` based on a [`UserHandle16`]. +pub type RegisteredCredential16<'reg> = RegisteredCredential<'reg, 16>; /// Credential used in authentication ceremonies. /// /// Similar to [`RegisteredCredential`] except designed to only contain the necessary data to complete @@ -1135,6 +1106,12 @@ impl<'cred, 'user, const USER_LEN: usize, PublicKey> ) } } +/// `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>; /// Convenience aggregate error that rolls up all errors into one. #[derive(Debug)] pub enum AggErr { diff --git a/src/request.rs b/src/request.rs @@ -1,5 +1,6 @@ #[cfg(doc)] use super::{ + hash::hash_set::FixedCapHashSet, request::{ auth::{ AllowedCredential, AllowedCredentials, CredentialSpecificExtension, @@ -21,8 +22,6 @@ use crate::{ CredentialId, Origin, Response, SentChallenge, }, }; -#[cfg(any(doc, not(feature = "serializable_server_state")))] -use core::hash::{BuildHasher, Hash, Hasher}; use core::{ borrow::Borrow, fmt::{self, Display, Formatter}, @@ -30,10 +29,10 @@ use core::{ str::FromStr, }; use rsa::sha2::{Digest as _, Sha256}; +#[cfg(any(doc, not(feature = "serializable_server_state")))] +use std::time::Instant; #[cfg(feature = "serializable_server_state")] use std::time::SystemTime; -#[cfg(any(doc, not(feature = "serializable_server_state")))] -use std::{collections::HashSet, time::Instant}; use url::Url as Uri; /// Contains functionality for beginning the /// [authentication ceremony](https://www.w3.org/TR/webauthn-3/#authentication-ceremony). @@ -41,9 +40,9 @@ use url::Url as Uri; /// # Examples /// /// ``` -/// # #[cfg(not(feature = "serializable_server_state"))] -/// # use webauthn_rp::request::{FixedCapHashSet, InsertResult}; +/// # use core::convert; /// # use webauthn_rp::{ +/// # hash::hash_set::FixedCapHashSet, /// # request::{ /// # auth::{AllowedCredentials, DiscoverableCredentialRequestOptions, NonDiscoverableCredentialRequestOptions}, /// # register::UserHandle64, @@ -52,16 +51,13 @@ use url::Url as Uri; /// # response::{AuthTransports, CredentialId, CRED_ID_MIN_LEN}, /// # AggErr, /// # }; -/// # #[cfg(not(feature = "serializable_server_state"))] /// 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()?; -/// # #[cfg(not(feature = "serializable_server_state"))] -/// assert!(matches!( -/// ceremonies.insert_or_replace_all_expired(server), -/// InsertResult::Success -/// )); -/// # #[cfg(all(feature = "custom", not(feature = "serializable_server_state")))] +/// assert!( +/// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) +/// ); +/// # #[cfg(feature = "custom")] /// let mut ceremonies_2 = FixedCapHashSet::new(128); /// # #[cfg(feature = "serde")] /// assert!(serde_json::to_string(&client).is_ok()); @@ -71,11 +67,10 @@ use url::Url as Uri; /// # #[cfg(feature = "custom")] /// let (server_2, client_2) = /// NonDiscoverableCredentialRequestOptions::second_factor(&rp_id, creds)?.start_ceremony()?; -/// # #[cfg(all(feature = "custom", not(feature = "serializable_server_state")))] -/// assert!(matches!( -/// ceremonies_2.insert_or_replace_all_expired(server_2), -/// InsertResult::Success -/// )); +/// # #[cfg(feature = "custom")] +/// assert!( +/// ceremonies_2.insert_remove_all_expired(server_2).map_or(false, convert::identity) +/// ); /// # #[cfg(all(feature = "custom", feature = "serde"))] /// assert!(serde_json::to_string(&client_2).is_ok()); /// /// Extract `UserHandle` from session cookie. @@ -108,9 +103,9 @@ pub mod error; /// # Examples /// /// ``` -/// # #[cfg(not(feature = "serializable_server_state"))] -/// # use webauthn_rp::request::{FixedCapHashSet, InsertResult}; +/// # use core::convert; /// # use webauthn_rp::{ +/// # hash::hash_set::FixedCapHashSet, /// # request::{ /// # register::{ /// # PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle, USER_HANDLE_MAX_LEN, UserHandle64, @@ -120,7 +115,7 @@ pub mod error; /// # response::{AuthTransports, CredentialId, CRED_ID_MIN_LEN}, /// # AggErr, /// # }; -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom"))] +/// # #[cfg(feature = "custom")] /// let mut ceremonies = FixedCapHashSet::new(128); /// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// # #[cfg(feature = "custom")] @@ -132,11 +127,10 @@ pub mod error; /// # #[cfg(feature = "custom")] /// let (server, client) = PublicKeyCredentialCreationOptions::passkey(&rp_id, user.clone(), creds) /// .start_ceremony()?; -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom"))] -/// assert!(matches!( -/// ceremonies.insert_or_replace_all_expired(server), -/// InsertResult::Success -/// )); +/// # #[cfg(feature = "custom")] +/// assert!( +/// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) +/// ); /// # #[cfg(all(feature = "serde", feature = "custom"))] /// assert!(serde_json::to_string(&client).is_ok()); /// # #[cfg(feature = "custom")] @@ -144,11 +138,10 @@ pub mod error; /// # #[cfg(feature = "custom")] /// let (server_2, client_2) = /// PublicKeyCredentialCreationOptions::second_factor(&rp_id, user, creds_2).start_ceremony()?; -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom"))] -/// assert!(matches!( -/// ceremonies.insert_or_replace_all_expired(server_2), -/// InsertResult::Success -/// )); +/// # #[cfg(feature = "custom")] +/// assert!( +/// ceremonies.insert_remove_all_expired(server_2).map_or(false, convert::identity) +/// ); /// # #[cfg(all(feature = "serde", feature = "custom"))] /// assert!(serde_json::to_string(&client_2).is_ok()); /// /// Extract `UserHandle` from session cookie or storage if this is not the first credential registered. @@ -205,7 +198,6 @@ 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. - #[expect(clippy::unwrap_used, reason = "we want to crash when there is a bug")] pub(super) const BASE64_LEN: usize = super::base64url_nopad_len(16).unwrap(); /// Generates a random `Challenge`. /// @@ -915,7 +907,7 @@ impl<'a: 'b + 'c, 'b, 'c> TryFrom<&'a str> for DomainOrigin<'b, 'c> { } /// [`PublicKeyCredentialDescriptor`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialdescriptor) /// associated with a registered credential. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct PublicKeyCredentialDescriptor<T> { /// [`id`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialdescriptor-id). pub id: CredentialId<T>, @@ -1498,154 +1490,7 @@ trait Ceremony<const USER_LEN: usize, const DISCOVERABLE: bool> { } } /// `300_000` milliseconds is equal to five minutes. -#[expect( - clippy::unwrap_used, - reason = "clearly correct, Option::unwrap is const, and better than unsafe" -)] pub(super) const THREE_HUNDRED_THOUSAND: NonZeroU32 = NonZeroU32::new(300_000).unwrap(); - -/// [`Hasher`] whose `write_*` methods simply store up to 64 bits of the passed argument _as is_ overwriting -/// any previous state. -/// -/// This is designed to only be used indirectly via a hash map whose keys are randomly generated on the server -/// based on at least 64 bits—the size of the integer returned from [`Self::finish`]—of entropy. -/// This makes this `Hasher` usable (and ideal) in only the most niche circumstances. -/// -/// [`RegistrationServerState`], [`DiscoverableAuthenticationServerState`], and -/// [`NonDiscoverableAuthenticationServerState`] implement [`Hash`] by simply writing the -/// contained [`Challenge`]; thus when they are stored in a hashed collection (e.g., [`FixedCapHashSet`]), one can -/// optimize without fear by using this `Hasher` since `Challenge`s are immutable and can only ever be created on -/// the server via [`Challenge::new`] (and equivalently [`Challenge::default`]). `RegistrationServerState`, -/// `DiscoverableAuthenticationServerState`, and `NonDiscoverableAuthenticationServerState` are also immutable and -/// only constructable via [`PublicKeyCredentialCreationOptions::start_ceremony`], -/// [`DiscoverableCredentialRequestOptions::start_ceremony`], and -/// [`NonDiscoverableCredentialRequestOptions::start_ceremony`] respectively. Since `Challenge` is already based on -/// a random `u128`, other `Hasher`s will be slower and likely produce lower-quality hashes (and never -/// higher quality). -#[cfg_attr(docsrs, doc(cfg(not(feature = "serializable_server_state"))))] -#[cfg(any(doc, not(feature = "serializable_server_state")))] -#[derive(Debug)] -pub struct IdentityHasher(u64); -// Note it is _not_ required for `write_*` methods to do the same thing as other `write_*` methods -// (e.g., `Self::write_u64` may not be the same thing as 8 calls to `Self::write_u8`). -#[cfg(any(doc, not(feature = "serializable_server_state")))] -impl Hasher for IdentityHasher { - /// Returns `0` if no `write_*` calls have been made; otherwise returns the result of the most recent - /// `write_*` call. - #[inline] - fn finish(&self) -> u64 { - self.0 - } - /// Writes `i` to `self`. - #[inline] - fn write_u64(&mut self, i: u64) { - self.0 = i; - } - /// Sign-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[expect( - clippy::as_conversions, - clippy::cast_sign_loss, - reason = "we simply need to convert into a u64 in a deterministic way" - )] - #[inline] - fn write_i8(&mut self, i: i8) { - self.write_u64(i as u64); - } - /// Sign-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[expect( - clippy::as_conversions, - clippy::cast_sign_loss, - reason = "we simply need to convert into a u64 in a deterministic way" - )] - #[inline] - fn write_i16(&mut self, i: i16) { - self.write_u64(i as u64); - } - /// Sign-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[expect( - clippy::as_conversions, - clippy::cast_sign_loss, - reason = "we simply need to convert into a u64 in a deterministic way" - )] - #[inline] - fn write_i32(&mut self, i: i32) { - self.write_u64(i as u64); - } - /// Redirects to [`Self::write_u64`]. - #[expect( - clippy::as_conversions, - clippy::cast_sign_loss, - reason = "we simply need to convert into a u64 in a deterministic way" - )] - #[inline] - fn write_i64(&mut self, i: i64) { - self.write_u64(i as u64); - } - /// Truncates `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[expect( - clippy::as_conversions, - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "we simply need to convert into a u64 in a deterministic way" - )] - #[inline] - fn write_i128(&mut self, i: i128) { - self.write_u64(i as u64); - } - /// Zero-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[inline] - fn write_u8(&mut self, i: u8) { - self.write_u64(u64::from(i)); - } - /// Zero-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[inline] - fn write_u16(&mut self, i: u16) { - self.write_u64(u64::from(i)); - } - /// Zero-extends `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[inline] - fn write_u32(&mut self, i: u32) { - self.write_u64(u64::from(i)); - } - /// Truncates `i` to a [`u64`] before redirecting to [`Self::write_u64`]. - #[expect( - clippy::as_conversions, - clippy::cast_possible_truncation, - reason = "we simply need to convert into a u64 in a deterministic way" - )] - #[inline] - fn write_u128(&mut self, i: u128) { - self.write_u64(i as u64); - } - /// This does nothing iff `bytes.len() < 8`; otherwise the first 8 bytes are converted - /// to a [`u64`] that is written via [`Self::write_u64`]; - #[expect(clippy::host_endian_bytes, reason = "endianness does not matter")] - #[inline] - fn write(&mut self, bytes: &[u8]) { - if let Some(data) = bytes.get(..8) { - let mut val = [0; 8]; - val.copy_from_slice(data); - self.write_u64(u64::from_ne_bytes(val)); - } - } -} -/// [`BuildHasher`] of an [`IdentityHasher`]. -/// -/// This MUST only be used with hash maps with keys that are randomly generated on the server based on at least 64 -/// bits of entropy. -#[cfg_attr(docsrs, doc(cfg(not(feature = "serializable_server_state"))))] -#[cfg(any(doc, not(feature = "serializable_server_state")))] -#[derive(Clone, Copy, Debug)] -pub struct BuildIdentityHasher; -#[cfg_attr(docsrs, doc(cfg(not(feature = "serializable_server_state"))))] -#[cfg(not(feature = "serializable_server_state"))] -impl BuildHasher for BuildIdentityHasher { - type Hasher = IdentityHasher; - #[inline] - fn build_hasher(&self) -> Self::Hasher { - IdentityHasher(0) - } -} /// "Ceremonies" stored on the server that expire after a certain duration. /// /// Types like [`RegistrationServerState`] and [`DiscoverableAuthenticationServerState`] are based on [`Challenge`]s @@ -1660,249 +1505,6 @@ pub trait TimedCeremony { /// Returns the `SystemTime` the ceremony expires. #[cfg(all(not(doc), feature = "serializable_server_state"))] fn expiration(&self) -> SystemTime; - /// Returns the `SentChallenge`. - fn sent_challenge(&self) -> SentChallenge; -} -/// Fixed-capacity hash set that only inserts items when there is available capacity. -/// -/// This should only be used if _both_ of the following conditions are met: -/// -/// * Application is already protected from memory exhaustion attacks (e.g., users must be connected -/// via VPN). -/// * There are legitimate reasons for an in-memory collection to grow unbounded. -/// -/// The first point is necessary; otherwise an attacker could trivially slow down the application -/// by causing repeated inserts forcing repeated calls to functions like [`Self::retain`]. The second -/// point is necessary; otherwise any in-memory collection would suffice. -/// -/// When `T` is a [`TimedCeremony`], there are legitimate reasons why a ceremony will never finish (e.g., -/// an outage could kill a user's connection after starting a ceremony). The longer the application -/// runs the more such instances occur to the point where the in-memory collection is full of expired -/// ceremonies. Since this should rarely occur and as long as [`Self::capacity`] is appropriate, -/// [`Self::insert`] should almost always succeed; however very rarely there will be a point when -/// one will have to [`Self::remove_expired_ceremonies`]. A vast majority of the time a user -/// will complete the ceremony which requires ownership of the `TimedCeremony` which in turn requires -/// [`Self::take`] which will add an available slot. -#[cfg_attr(docsrs, doc(cfg(not(feature = "serializable_server_state"))))] -#[cfg(any(doc, not(feature = "serializable_server_state")))] -#[derive(Debug)] -pub struct FixedCapHashSet<T, S = BuildIdentityHasher>(HashSet<T, S>); -#[cfg(any(doc, not(feature = "serializable_server_state")))] -impl<T> FixedCapHashSet<T, BuildIdentityHasher> { - /// Creates an empty `FixedCapHashSet` with at least the specified capacity. - /// - /// The hash set will be able to hold at least `capacity` elements without reallocating. This method is allowed - /// to allocate for more elements than `capacity`. - #[inline] - #[must_use] - pub fn new(capacity: usize) -> Self { - Self(HashSet::with_capacity_and_hasher( - capacity, - BuildIdentityHasher, - )) - } -} -#[cfg(any(doc, not(feature = "serializable_server_state")))] -impl<T, S> FixedCapHashSet<T, S> { - /// Creates an empty `FixedCapHashSet` with at least the specified capacity, using `hasher` to hash the keys. - /// - /// The hash set will be able to hold at least `capacity` elements without reallocating. This method is allowed - /// to allocate for more elements than `capacity`. - #[inline] - #[must_use] - pub fn new_with_hasher(capacity: usize, hasher: S) -> Self { - Self(HashSet::with_capacity_and_hasher(capacity, hasher)) - } - /// Returns the immutable capacity. - /// - /// This number is a lower bound; the `FixedCapHashSet` might be able to hold more, but is guaranteed to be - /// able to hold at least this many. - #[inline] - #[must_use] - pub fn capacity(&self) -> usize { - self.0.capacity() - } - /// Clears the set, removing all values. - #[inline] - pub fn clear(&mut self) { - self.0.clear(); - } - /// Returns the number of elements in the set. - #[inline] - #[must_use] - pub fn len(&self) -> usize { - self.0.len() - } - /// Returns `true` iff the set contains no elements. - #[inline] - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - /// Retains only the elements specified by the predicate. - /// - /// In other words, remove all elements `e` for which `f(&e)` returns `false`. The elements are visited in - /// unsorted (and unspecified) order. - #[inline] - pub fn retain<F>(&mut self, f: F) - where - F: FnMut(&T) -> bool, - { - self.0.retain(f); - } -} -#[cfg(any(doc, not(feature = "serializable_server_state")))] -impl<T: TimedCeremony, S> FixedCapHashSet<T, S> { - /// Removes all expired ceremonies. - #[inline] - pub fn remove_expired_ceremonies(&mut self) { - // Even though it's more accurate to check the current `Instant` for each ceremony, we elect to capture - // the `Instant` we begin iteration for performance reasons. It's unlikely an appreciable amount of - // additional ceremonies would be removed. - let now = Instant::now(); - self.retain(|v| v.expiration() >= now); - } -} -/// Result returned from [`FixedCapHashSet::insert`], [`FixedCapHashSet::insert_or_replace_expired`], and -/// [`FixedCapHashSet::insert_or_replace_all_expired`]. -#[cfg_attr(docsrs, doc(cfg(not(feature = "serializable_server_state"))))] -#[cfg(any(doc, not(feature = "serializable_server_state")))] -#[derive(Clone, Copy, Debug)] -pub enum InsertResult { - /// Value was successfully inserted. - Success, - /// Value was not inserted since the capacity was full. - CapacityFull, - /// Value was not inserted since the value already existed. - /// - /// When the keys are based on [`Challenge`]s, this should almost never occur since `Challenge`s - /// are 16 bytes of random data. - Duplicate, -} -#[cfg(any(doc, not(feature = "serializable_server_state")))] -impl<T: Eq + Hash, S: BuildHasher> FixedCapHashSet<T, S> { - /// Returns `true` iff the set contains a value. - /// - /// The value may be any borrowed form of the set's value type, but `Hash` and `Eq` on the borrowed form _must_ - /// match those for the value type. - #[inline] - #[must_use] - pub fn contains<Q>(&self, value: &Q) -> bool - where - T: Borrow<Q>, - Q: Eq + Hash + ?Sized, - { - self.0.contains(value) - } - /// Returns a reference to the value in the set, if any, that is equal to the given value. - /// - /// The value may be any borrowed form of the set's value type, but `Hash` and `Eq` on the borrowed form _must_ - /// match those for the value type. - #[inline] - #[must_use] - pub fn get<Q>(&self, value: &Q) -> Option<&T> - where - T: Borrow<Q>, - Q: Eq + Hash + ?Sized, - { - self.0.get(value) - } - /// Removes a value from the set. Returns whether the value was present in the set. - /// - /// The value may be any borrowed form of the set's value type, but `Hash` and `Eq` on the borrowed form _must_ - /// match those for the value type. - #[inline] - pub fn remove<Q>(&mut self, value: &Q) -> bool - where - T: Borrow<Q>, - Q: Eq + Hash + ?Sized, - { - self.0.remove(value) - } - /// Removes and returns the value in the set, if any, that is equal to the given one. - /// - /// The value may be any borrowed form of the set's value type, but `Hash` and `Eq` on the borrowed form _must_ - /// match those for the value type. - #[inline] - pub fn take<Q>(&mut self, value: &Q) -> Option<T> - where - T: Borrow<Q>, - Q: Eq + Hash + ?Sized, - { - self.0.take(value) - } - /// Adds `value` to the set iff [`Self::capacity`] `>` [`Self::len`]. - #[inline] - pub fn insert(&mut self, value: T) -> InsertResult { - if self.len() == self.capacity() { - InsertResult::CapacityFull - } else if self.0.insert(value) { - InsertResult::Success - } else { - InsertResult::Duplicate - } - } -} -#[cfg(any(doc, not(feature = "serializable_server_state")))] -impl<T: Borrow<SentChallenge> + Eq + Hash + TimedCeremony, S: BuildHasher> FixedCapHashSet<T, S> { - /// Adds a ceremony to the set. - /// - /// This will only insert `value` iff [`Self::capacity`] `>` [`Self::len`]. When [`Self::len`] `==` - /// [`Self::capacity`], this will iterate items in the set until an expired ceremony is encountered; at which - /// point, the expired ceremony will be removed before `value` is inserted. In the event no expired ceremonies - /// exist, [`InsertResult::CapacityFull`] will be returned. - /// - /// If one wants to avoid the potentially expensive operation of iterating the set for an expired ceremony, - /// call [`Self::insert`]. Alternatively one can call [`Self::insert_or_replace_all_expired`] to avoid - /// repeatedly iterating the hash set once its capacity is full. - #[inline] - pub fn insert_or_replace_expired(&mut self, value: T) -> InsertResult { - if self.len() == self.capacity() { - let now = Instant::now(); - self.0 - .iter() - .try_fold((), |(), v| { - if v.expiration() < now { - Err(v.sent_challenge()) - } else { - Ok(()) - } - }) - .map_or_else( - |chall| { - self.remove(&chall); - if self.0.insert(value) { - InsertResult::Success - } else { - InsertResult::Duplicate - } - }, - |()| InsertResult::CapacityFull, - ) - } else if self.0.insert(value) { - InsertResult::Success - } else { - InsertResult::Duplicate - } - } - /// Adds a ceremony to the set. - /// - /// This will only insert `value` iff [`Self::capacity`] `>` [`Self::len`]. When [`Self::len`] `==` - /// [`Self::capacity`], this will [`Self::remove_expired_ceremonies`] before `value` is inserted. In the event - /// no expired ceremonies exist, [`InsertResult::CapacityFull`] will be returned. - #[inline] - pub fn insert_or_replace_all_expired(&mut self, value: T) -> InsertResult { - if self.len() == self.capacity() { - self.remove_expired_ceremonies(); - } - if self.len() == self.capacity() { - InsertResult::CapacityFull - } else if self.0.insert(value) { - InsertResult::Success - } else { - InsertResult::Duplicate - } - } } #[cfg(test)] mod tests { @@ -1919,9 +1521,9 @@ mod tests { register::{ AuthenticationExtensionsPrfOutputs, AuthenticatorAttestation, AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputs, - CompressedP256PubKey, CompressedP384PubKey, CompressedPubKey, - CredentialProtectionPolicy, DynamicState, Ed25519PubKey, Registration, - RsaPubKey, StaticState, UncompressedPubKey, + ClientExtensionsOutputsStaticState, CompressedP256PubKey, CompressedP384PubKey, + CompressedPubKey, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, + Registration, RsaPubKey, StaticState, UncompressedPubKey, }, }, }, @@ -1933,7 +1535,7 @@ mod tests { Extension as AuthExt, NonDiscoverableCredentialRequestOptions, PrfInputOwned, }, register::{ - CredProtect, Extension as RegExt, PublicKeyCredentialCreationOptions, + CredProtect, Extension as RegExt, FourToSixtyThree, PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationVerificationOptions, UserHandle, }, }; @@ -1970,7 +1572,7 @@ mod tests { const CBOR_TRUE: u8 = CBOR_SIMPLE | 21; #[test] #[cfg(feature = "custom")] - fn ed25519_reg() -> Result<(), AggErr> { + 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 = PublicKeyCredentialCreationOptions::passkey( @@ -1985,8 +1587,15 @@ mod tests { opts.challenge = Challenge(0); opts.extensions = RegExt { cred_props: None, - cred_protect: CredProtect::UserVerificationRequired(ExtensionInfo::RequireEnforceValue), - min_pin_length: Some((10, ExtensionInfo::RequireEnforceValue)), + cred_protect: CredProtect::UserVerificationRequired( + false, + ExtensionInfo::RequireEnforceValue, + ), + min_pin_length: Some(( + FourToSixtyThree::new(10) + .unwrap_or_else(|| unreachable!("bug in FourToSixyThree::new")), + ExtensionInfo::RequireEnforceValue, + )), prf: Some(ExtensionInfo::RequireEnforceValue), }; let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); @@ -2313,7 +1922,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn ed25519_auth() -> Result<(), AggErr> { + 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 { @@ -2511,6 +2120,9 @@ mod tests { cred_protect: CredentialProtectionPolicy::None, hmac_secret: Some(true), }, + client_extension_results: ClientExtensionsOutputsStaticState { + prf: Some(AuthenticationExtensionsPrfOutputs { enabled: true }), + } }, DynamicState { user_verified: true, @@ -2525,7 +2137,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn p256_reg() -> Result<(), AggErr> { + 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 = PublicKeyCredentialCreationOptions::passkey( @@ -2783,7 +2395,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn p256_auth() -> Result<(), AggErr> { + fn es256_auth() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); opts.0.challenge = Challenge(0); @@ -2879,6 +2491,7 @@ mod tests { cred_protect: CredentialProtectionPolicy::None, hmac_secret: None, }, + client_extension_results: ClientExtensionsOutputsStaticState { prf: None } }, DynamicState { user_verified: true, @@ -2893,7 +2506,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn p384_reg() -> Result<(), AggErr> { + 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 = PublicKeyCredentialCreationOptions::passkey( @@ -3186,7 +2799,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn p384_auth() -> Result<(), AggErr> { + fn es384_auth() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); opts.0.challenge = Challenge(0); @@ -3283,6 +2896,7 @@ mod tests { cred_protect: CredentialProtectionPolicy::None, hmac_secret: None, }, + client_extension_results: ClientExtensionsOutputsStaticState { prf: None } }, DynamicState { user_verified: true, @@ -3297,7 +2911,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn rsa_reg() -> Result<(), AggErr> { + 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 = PublicKeyCredentialCreationOptions::passkey( @@ -3799,7 +3413,7 @@ mod tests { } #[test] #[cfg(feature = "custom")] - fn rsa_auth() -> Result<(), AggErr> { + fn rs256_auth() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); opts.0.challenge = Challenge(0); @@ -3946,6 +3560,7 @@ mod tests { cred_protect: CredentialProtectionPolicy::None, hmac_secret: None, }, + client_extension_results: ClientExtensionsOutputsStaticState { prf: None } }, DynamicState { user_verified: true, diff --git a/src/request/auth.rs b/src/request/auth.rs @@ -3,7 +3,10 @@ use super::{ super::response::{ Backup, CollectedClientData, Flag, auth::AuthenticatorData, - register::{DynamicState, StaticState}, + register::{ + AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, + DynamicState, StaticState, + }, }, AsciiDomain, DomainOrigin, Url, register::{self, PublicKeyCredentialCreationOptions}, @@ -18,7 +21,7 @@ use super::{ HmacSecret, NonDiscoverableAuthentication, error::{AuthCeremonyErr, ExtensionErr, OneOrTwo}, }, - register::CompressedPubKey, + register::{CompressedPubKey, CredentialProtectionPolicy}, }, }, BackupReq, Ceremony, CeremonyOptions, Challenge, CredentialId, Credentials, ExtensionReq, Hint, @@ -85,9 +88,10 @@ impl SignatureCounterEnforcement { /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). /// /// This is only applicable if -/// [`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) -/// is `true` when registering a new credential with [`register::Extension::prf`] and /// [`PublicKeyCredentialRequestOptions::user_verification`] is [`UserVerificationRequirement::Required`]. +/// Additionally [`AuthenticatorExtensionOutputStaticState::hmac_secret`] must either be `None` or `Some(true)` +/// and [`ClientExtensionsOutputsStaticState::prf`] must be `None` or +/// `Some(AuthenticationExtensionsPrfOutputs { enabled: true })`. /// /// Unlike the spec, it is forbidden for /// [the decrypted outputs](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfoutputs) to be @@ -103,7 +107,7 @@ impl SignatureCounterEnforcement { /// /// For the owned analog, see [`PrfInputOwned`]. /// -/// When relying on [`DiscoverableCredentialRequestOptions`]), one will likely use a static PRF input for _all_ +/// When relying on [`DiscoverableCredentialRequestOptions`], one will likely use a static PRF input for _all_ /// credentials since rolling over PRF inputs is not feasible. One uses this type for such a thing. In other words, /// `'a` will likely be `'static` and [`Self::second`] will likely be `None`. #[derive(Clone, Copy, Debug)] @@ -113,19 +117,28 @@ pub struct PrfInput<'a> { /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second). pub second: Option<&'a [u8]>, /// Response requirements. + /// + /// Note this is only applicable for authenticators that implement the + /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension) extension on top of the + /// [`hmac-secret`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-extension) + /// extension since the data is encrypted and is part of the [`AuthenticatorData`]. pub ext_info: ExtensionReq, } /// Owned version of [`PrfInput`]. /// /// When relying on [`NonDiscoverableCredentialRequestOptions`], it's recommended to use credential-specific PRF /// inputs that are continuously rolled over. One uses this type for such a thing. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct PrfInputOwned { /// [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first). pub first: Vec<u8>, /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second). pub second: Option<Vec<u8>>, - /// Response requirements. + /// + /// Note this is only applicable for authenticators that implement the + /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension) extension on top of the + /// [`hmac-secret`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-extension) + /// extension since the data is encrypted and is part of the [`AuthenticatorData`]. pub ext_info: ExtensionReq, } /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client. @@ -139,7 +152,7 @@ pub struct Extension<'prf> { } /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client that /// are credential-specific which among other things implies a non-discoverable request. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct CredentialSpecificExtension { /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension). /// @@ -148,7 +161,7 @@ pub struct CredentialSpecificExtension { } /// Registered credential used in /// [`allowCredentials`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials). -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct AllowedCredential { /// The registered credential. pub credential: PublicKeyCredentialDescriptor<Vec<u8>>, @@ -164,8 +177,14 @@ impl From<PublicKeyCredentialDescriptor<Vec<u8>>> for AllowedCredential { } } } +impl From<AllowedCredential> for PublicKeyCredentialDescriptor<Vec<u8>> { + #[inline] + fn from(credential: AllowedCredential) -> Self { + credential.credential + } +} /// Queue of unique [`AllowedCredential`]s. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct AllowedCredentials { /// Allowed credentials. creds: Vec<AllowedCredential>, @@ -281,6 +300,29 @@ impl From<&AllowedCredentials> for Vec<CredInfo> { }) } } +impl From<AllowedCredentials> for Vec<PublicKeyCredentialDescriptor<Vec<u8>>> { + #[inline] + fn from(value: AllowedCredentials) -> Self { + let mut creds = Self::with_capacity(value.creds.len()); + value.creds.into_iter().fold((), |(), cred| { + creds.push(cred.credential); + }); + creds + } +} +impl From<Vec<PublicKeyCredentialDescriptor<Vec<u8>>>> for AllowedCredentials { + #[inline] + fn from(value: Vec<PublicKeyCredentialDescriptor<Vec<u8>>>) -> Self { + let mut creds = Self::with_capacity(value.len()); + value.into_iter().fold((), |(), credential| { + creds.push(AllowedCredential { + credential, + extension: CredentialSpecificExtension { prf: None }, + }); + }); + creds + } +} /// Helper that verifies the overlap of [`DiscoverableCredentialRequestOptions::start_ceremony`] and /// [`DiscoverableAuthenticationServerState::decode`]. fn validate_discoverable_options_helper( @@ -403,11 +445,11 @@ impl<'rp_id, 'prf> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { pub const fn options(&mut self) -> &mut PublicKeyCredentialRequestOptions<'rp_id, 'prf> { &mut self.options } - /// Returns the `slice` of [`AllowedCredential`]s. + /// Returns a reference to the [`AllowedCredential`]s. #[inline] #[must_use] - pub fn allow_credentials(&self) -> &[AllowedCredential] { - self.allow_credentials.as_ref() + pub const fn allow_credentials(&self) -> &AllowedCredentials { + &self.allow_credentials } /// Creates a `NonDiscoverableCredentialRequestOptions` containing /// [`PublicKeyCredentialRequestOptions::second_factor`] and the passed [`AllowedCredentials`]. @@ -630,8 +672,37 @@ impl<'rp_id, 'prf> NonDiscoverableAuthenticationClientState<'rp_id, 'prf> { &self.0 } } -/// `PrfInput` and `PrfInputOwned` without the actual data sent to reduce memory usage when storing [`AuthenticationServerState`] -/// in an in-memory collection. +/// The possible combinations of an [`AuthenticatedCredential`]'s [`StaticState`]'s +/// `extensions.hmac_secret` and `client_extension_results.prf`. +/// +/// Note we ensure in `crate::verify_static_and_dynamic_state` that `hmac_secret` does not exist when +/// `prf` does not exist, `hmac_secret` does not exist or is `false` when `prf` is `false`, or +/// `hmac_secret` does not exist or is `true` when `prf` is `true`. +#[derive(Clone, Copy)] +enum CredPrf { + /// No `prf` or `hmac_secret`. + None, + /// `prf.enabled` is `false` but there is no `hmac_secret`. + FalseNoHmac, + /// `prf.enabled` and `hmac_secret` are `false`. + FalseFalseHmac, + /// `prf.enabled` is `true` but there is no `hmac_secret`. + TrueNoHmac, + /// `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)] enum ServerPrfInfo { /// `PrfInput::second` was `None`. @@ -647,72 +718,95 @@ impl ServerPrfInfo { } } /// Validates `val` based on the passed arguments. + /// + /// It's not possible to request the PRF extension without sending `UserVerificationRequirement::Required`; + /// thus `user_verified` will always be `true` when sending PRF; otherwise ceremony validation will error. + /// However when we _don't_ send the PRF extension _and_ we don't error on an unsolicited response, it's + /// possible to receive an `HmacSecret` without the user having been verified; thus we only ensure + /// `user_verified` is true when we don't error on unsolicted responses _and_ we didn't send the PRF extension. fn validate( val: Option<Self>, - prf_capable: bool, + user_verified: bool, + cred_prf: CredPrf, hmac: HmacSecret, err_unsolicited: bool, ) -> Result<(), ExtensionErr> { match hmac { - HmacSecret::None => { - if prf_capable { - val.map_or(Ok(()), |input| { - if matches!(input.ext_info(), ExtensionReq::Allow) { + HmacSecret::None => val.map_or(Ok(()), |input| { + if matches!(input.ext_info(), ExtensionReq::Allow) { + if cred_prf.is_prf_capable() { + Ok(()) + } else { + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + } + } else { + match cred_prf { + CredPrf::None | CredPrf::TrueNoHmac => Ok(()), + CredPrf::FalseNoHmac | CredPrf::FalseFalseHmac => { + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + } + CredPrf::TrueTrueHmac => Err(ExtensionErr::MissingHmacSecret), + } + } + }), + HmacSecret::One => val.map_or_else( + || { + if err_unsolicited { + Err(ExtensionErr::ForbiddenHmacSecret) + } else if cred_prf.is_prf_capable() { + if user_verified { Ok(()) } else { - Err(ExtensionErr::MissingHmacSecret) + Err(ExtensionErr::UserNotVerifiedHmacSecret) } - }) - } else { - // We check if the PRF extension was requested on an incapable credential; - // if so, we error. - val.map_or(Ok(()), |_| Err(ExtensionErr::HmacSecretForPrfIncapableCred)) - } - } - HmacSecret::One => { - if prf_capable { - val.map_or_else( - || { - if err_unsolicited { - Err(ExtensionErr::ForbiddenHmacSecret) - } else { - Ok(()) - } - }, - |input| match input { - Self::One(_) => Ok(()), - Self::Two(_) => Err(ExtensionErr::InvalidHmacSecretValue( - OneOrTwo::Two, - OneOrTwo::One, - )), - }, - ) - } else { - Err(ExtensionErr::HmacSecretForPrfIncapableCred) - } - } - HmacSecret::Two => { - if prf_capable { - val.map_or_else( - || { - if err_unsolicited { - Err(ExtensionErr::ForbiddenHmacSecret) - } else { - Ok(()) - } - }, - |input| match input { - Self::One(_) => Err(ExtensionErr::InvalidHmacSecretValue( - OneOrTwo::One, - OneOrTwo::Two, - )), - Self::Two(_) => Ok(()), - }, - ) - } else { - Err(ExtensionErr::HmacSecretForPrfIncapableCred) - } - } + } else { + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + } + }, + |info| { + if matches!(info, Self::One(_)) { + if cred_prf.is_prf_capable() { + Ok(()) + } else { + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + } + } else { + Err(ExtensionErr::InvalidHmacSecretValue( + OneOrTwo::Two, + OneOrTwo::One, + )) + } + }, + ), + HmacSecret::Two => val.map_or_else( + || { + if err_unsolicited { + Err(ExtensionErr::ForbiddenHmacSecret) + } else if cred_prf.is_prf_capable() { + if user_verified { + Ok(()) + } else { + Err(ExtensionErr::UserNotVerifiedHmacSecret) + } + } else { + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + } + }, + |info| { + if matches!(info, Self::Two(_)) { + if cred_prf.is_prf_capable() { + Ok(()) + } else { + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) + } + } else { + Err(ExtensionErr::InvalidHmacSecretValue( + OneOrTwo::One, + OneOrTwo::Two, + )) + } + }, + ), } } } @@ -786,13 +880,15 @@ impl ServerExtensionInfo { /// Note that this MUST only be called internally by `auth::validate_extensions`. fn validate_extensions( self, + user_verified: bool, auth_ext: AuthenticatorExtensionOutput, error_unsolicited: bool, - prf_capable: bool, + cred_prf: CredPrf, ) -> Result<(), ExtensionErr> { ServerPrfInfo::validate( self.prf, - prf_capable, + user_verified, + cred_prf, auth_ext.hmac_secret, error_unsolicited, ) @@ -801,15 +897,16 @@ impl ServerExtensionInfo { /// Validates the extensions. fn validate_extensions( ext: ServerExtensionInfo, + user_verified: bool, cred_ext: Option<ServerCredSpecificExtensionInfo>, auth_ext: AuthenticatorExtensionOutput, error_unsolicited: bool, - prf_capable: bool, + cred_prf: CredPrf, ) -> Result<(), ExtensionErr> { cred_ext.map_or_else( || { // No client-specific extensions, so we can simply focus on `ext`. - ext.validate_extensions(auth_ext, error_unsolicited, prf_capable) + ext.validate_extensions(user_verified, auth_ext, error_unsolicited, cred_prf) }, |c_ext| { // Must carefully process each extension based on overlap and which gets priority over the other. @@ -817,7 +914,8 @@ fn validate_extensions( || { ServerPrfInfo::validate( ext.prf, - prf_capable, + user_verified, + cred_prf, auth_ext.hmac_secret, error_unsolicited, ) @@ -825,7 +923,8 @@ fn validate_extensions( |_| { ServerPrfInfo::validate( c_ext.prf, - prf_capable, + user_verified, + cred_prf, auth_ext.hmac_secret, error_unsolicited, ) @@ -1053,7 +1152,7 @@ impl DiscoverableAuthenticationServerState { if cred.user_id == response.response.user_handle() { // Step 6 item 2. if cred.id == response.raw_id { - self.0.verify(rp_id, response, cred, options, None) + self.0.verify(true, rp_id, response, cred, options, None) } else { Err(AuthCeremonyErr::CredentialIdMismatch) } @@ -1147,7 +1246,7 @@ impl NonDiscoverableAuthenticationServerState { // Step 6 item 1. if c.id == cred.id { self.state - .verify(rp_id, response, cred, options, Some(c.ext)) + .verify(false, rp_id, response, cred, options, Some(c.ext)) } else { Err(AuthCeremonyErr::CredentialIdMismatch) } @@ -1214,6 +1313,7 @@ impl AuthenticationServerState { RsaKey: AsRef<[u8]>, >( self, + discoverable: bool, rp_id: &RpId, response: &'a Authentication<USER_LEN, DISCOVERABLE>, cred: &mut AuthenticatedCredential< @@ -1278,13 +1378,30 @@ impl AuthenticationServerState { response.authenticator_attachment, ) .and_then(|auth_attachment| { + let flags = auth_data.flags(); // Step 23. validate_extensions( self.extensions, + flags.user_verified, cred_ext, auth_data.extensions(), options.error_on_unsolicited_extensions, - cred.static_state.extensions.hmac_secret.unwrap_or_default(), + cred.static_state.client_extension_results.prf.map_or( + CredPrf::None, + |prf| { + if prf.enabled { + cred.static_state + .extensions + .hmac_secret + .map_or(CredPrf::TrueNoHmac, |_| CredPrf::TrueTrueHmac) + } else { + cred.static_state + .extensions + .hmac_secret + .map_or(CredPrf::FalseNoHmac, |_| CredPrf::FalseFalseHmac) + } + }, + ), ) .map_err(AuthCeremonyErr::Extension) .and_then(|()| { @@ -1293,7 +1410,6 @@ impl AuthenticationServerState { .sig_counter_enforcement .validate(cred.dynamic_state.sign_count, auth_data.sign_count()) .and_then(|sig_counter| { - let flags = auth_data.flags(); let prev_dyn_state = cred.dynamic_state; // Step 24 item 2. cred.dynamic_state.backup = flags.backup; @@ -1305,15 +1421,27 @@ impl AuthenticationServerState { cred.dynamic_state.sign_count = sig_counter; cred.dynamic_state.authenticator_attachment = auth_attachment; // Step 25. - crate::verify_static_and_dynamic_state( - &cred.static_state, - cred.dynamic_state, - ) - .map_err(|e| { + if flags.user_verified { + Ok(()) + } else { + match cred.static_state.extensions.cred_protect { + CredentialProtectionPolicy::None | CredentialProtectionPolicy::UserVerificationOptional => Ok(()), + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList => { + if discoverable { + Err(AuthCeremonyErr::DiscoverableCredProtectCredentialIdList) + } else { + Ok(()) + } + } + CredentialProtectionPolicy::UserVerificationRequired => { + Err(AuthCeremonyErr::UserNotVerifiedCredProtectRequired) + } + } + }.inspect_err(|_| { cred.dynamic_state = prev_dyn_state; - AuthCeremonyErr::Credential(e) + }).map(|()| { + prev_dyn_state != cred.dynamic_state }) - .map(|()| prev_dyn_state != cred.dynamic_state) }) }) }) @@ -1338,10 +1466,6 @@ impl TimedCeremony for AuthenticationServerState { fn expiration(&self) -> SystemTime { self.expiration } - #[inline] - fn sent_challenge(&self) -> SentChallenge { - self.challenge - } } impl TimedCeremony for DiscoverableAuthenticationServerState { #[cfg(any(doc, not(feature = "serializable_server_state")))] @@ -1354,10 +1478,6 @@ impl TimedCeremony for DiscoverableAuthenticationServerState { fn expiration(&self) -> SystemTime { self.0.expiration() } - #[inline] - fn sent_challenge(&self) -> SentChallenge { - self.0.sent_challenge() - } } impl TimedCeremony for NonDiscoverableAuthenticationServerState { #[cfg(any(doc, not(feature = "serializable_server_state")))] @@ -1370,10 +1490,6 @@ impl TimedCeremony for NonDiscoverableAuthenticationServerState { fn expiration(&self) -> SystemTime { self.state.expiration() } - #[inline] - fn sent_challenge(&self) -> SentChallenge { - self.state.sent_challenge() - } } impl<const USER_LEN: usize, const DISCOVERABLE: bool> Ceremony<USER_LEN, DISCOVERABLE> for AuthenticationServerState @@ -1527,28 +1643,77 @@ impl Ord for NonDiscoverableAuthenticationServerState { } #[cfg(test)] mod tests { + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + use super::{ + super::super::{ + AuthenticatedCredential, DynamicState, StaticState, UserHandle, + response::{ + Backup, + auth::{DiscoverableAuthenticatorAssertion, HmacSecret}, + register::{ + AuthenticationExtensionsPrfOutputs, AuthenticatorExtensionOutputStaticState, + ClientExtensionsOutputsStaticState, CredentialProtectionPolicy, Ed25519PubKey, + }, + }, + }, + AuthCeremonyErr, AuthenticationVerificationOptions, AuthenticatorAttachment, + AuthenticatorAttachmentEnforcement, CompressedPubKey, DiscoverableAuthentication, + ExtensionErr, OneOrTwo, PrfInput, SignatureCounterEnforcement, + }; + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] + use super::{ + super::{super::AggErr, Challenge, CredentialId, RpId, UserVerificationRequirement}, + DiscoverableCredentialRequestOptions, ExtensionReq, + }; #[cfg(all(feature = "custom", feature = "serializable_server_state"))] use super::{ super::{ - super::{ - AggErr, - bin::{Decode as _, Encode as _}, - }, + super::bin::{Decode as _, Encode as _}, AsciiDomain, AuthTransports, }, - AllowedCredential, AllowedCredentials, Challenge, CredentialId, - CredentialSpecificExtension, Credentials as _, DiscoverableAuthenticationServerState, - DiscoverableCredentialRequestOptions, Extension, ExtensionReq, - NonDiscoverableAuthenticationServerState, NonDiscoverableCredentialRequestOptions, - PrfInputOwned, PublicKeyCredentialDescriptor, RpId, UserVerificationRequirement, + AllowedCredential, AllowedCredentials, CredentialSpecificExtension, Credentials as _, + DiscoverableAuthenticationServerState, Extension, NonDiscoverableAuthenticationServerState, + NonDiscoverableCredentialRequestOptions, PrfInputOwned, PublicKeyCredentialDescriptor, }; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[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")) + ) + ))] use rsa::sha2::{Digest as _, Sha256}; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_BYTES: u8 = 0b010_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_TEXT: u8 = 0b011_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_MAP: u8 = 0b101_00000; #[test] #[cfg(all(feature = "custom", feature = "serializable_server_state"))] @@ -1745,4 +1910,422 @@ mod tests { ); Ok(()) } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestResponseOptions { + user_verified: bool, + hmac: HmacSecret, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + enum PrfCredOptions { + None, + FalseNoHmac, + FalseHmacFalse, + TrueNoHmac, + TrueHmacTrue, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestCredOptions { + cred_protect: CredentialProtectionPolicy, + prf: PrfCredOptions, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + enum PrfUvOptions { + /// `true` iff `UserVerificationRequirement::Required` should be used; otherwise + /// `UserVerificationRequirement::Preferred` is used. + None(bool), + Prf(PrfInput<'static>), + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestRequestOptions { + error_unsolicited: bool, + prf_uv: PrfUvOptions, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestOptions { + request: TestRequestOptions, + response: TestResponseOptions, + cred: TestCredOptions, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn generate_client_data_json() -> Vec<u8> { + let mut json = Vec::with_capacity(256); + json.extend_from_slice(br#"{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.as_slice()); + json + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn generate_authenticator_data_public_key_sig( + opts: TestResponseOptions, + ) -> ( + Vec<u8>, + CompressedPubKey<[u8; 32], [u8; 32], [u8; 48], Vec<u8>>, + Vec<u8>, + ) { + let mut authenticator_data = Vec::with_capacity(256); + authenticator_data.extend_from_slice( + [ + // 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). + 0b0000_0001 + | if opts.user_verified { + 0b0000_0100 + } else { + 0b0000_0000 + } + | if matches!(opts.hmac, HmacSecret::None) { + 0 + } else { + 0b1000_0000 + }, + // COUNTER. + // 0 as 32-bit big endian. + 0, + 0, + 0, + 0, + ] + .as_slice(), + ); + authenticator_data[..32] + .copy_from_slice(Sha256::digest("example.com".as_bytes()).as_slice()); + match opts.hmac { + HmacSecret::None => {} + HmacSecret::One => { + authenticator_data.extend_from_slice( + [ + CBOR_MAP | 1, + // 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_BYTES | 24, + 48, + ] + .as_slice(), + ); + authenticator_data.extend_from_slice([0; 48].as_slice()); + } + HmacSecret::Two => { + authenticator_data.extend_from_slice( + [ + CBOR_MAP | 1, + // 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_BYTES | 24, + 80, + ] + .as_slice(), + ); + authenticator_data.extend_from_slice([0; 80].as_slice()); + } + } + let len = authenticator_data.len(); + authenticator_data + .extend_from_slice(Sha256::digest(generate_client_data_json().as_slice()).as_slice()); + let sig_key = SigningKey::from_bytes(&[0; 32]); + let sig = sig_key.sign(authenticator_data.as_slice()).to_vec(); + authenticator_data.truncate(len); + ( + authenticator_data, + CompressedPubKey::Ed25519(Ed25519PubKey::from(sig_key.verifying_key().to_bytes())), + sig, + ) + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn validate(options: TestOptions) -> Result<(), AggErr> { + let rp_id = RpId::Domain("example.com".to_owned().try_into()?); + let user = UserHandle::from([0; 1]); + let (authenticator_data, credential_public_key, signature) = + generate_authenticator_data_public_key_sig(options.response); + let credential_id = CredentialId::try_from(vec![0; 16])?; + let authentication = DiscoverableAuthentication::new( + credential_id.clone(), + DiscoverableAuthenticatorAssertion::new( + generate_client_data_json(), + authenticator_data, + signature, + user, + ), + AuthenticatorAttachment::None, + ); + let auth_opts = AuthenticationVerificationOptions::<'static, 'static, &str, &str> { + allowed_origins: [].as_slice(), + allowed_top_origins: None, + auth_attachment_enforcement: AuthenticatorAttachmentEnforcement::Update(false), + backup_requirement: None, + error_on_unsolicited_extensions: options.request.error_unsolicited, + sig_counter_enforcement: SignatureCounterEnforcement::Fail, + update_uv: false, + #[cfg(feature = "serde_relaxed")] + client_data_json_relaxed: false, + }; + let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); + opts.0.challenge = Challenge(0); + opts.0.user_verification = UserVerificationRequirement::Preferred; + match options.request.prf_uv { + PrfUvOptions::None(required) => { + if required { + opts.0.user_verification = UserVerificationRequirement::Required; + }; + } + PrfUvOptions::Prf(input) => { + opts.0.user_verification = UserVerificationRequirement::Required; + opts.0.extensions.prf = Some(input); + } + } + let mut cred = AuthenticatedCredential::new( + (&credential_id).into(), + &user, + StaticState { + credential_public_key, + extensions: AuthenticatorExtensionOutputStaticState { + cred_protect: options.cred.cred_protect, + hmac_secret: match options.cred.prf { + PrfCredOptions::None + | PrfCredOptions::FalseNoHmac + | PrfCredOptions::TrueNoHmac => None, + PrfCredOptions::FalseHmacFalse => Some(false), + PrfCredOptions::TrueHmacTrue => Some(true), + }, + }, + client_extension_results: ClientExtensionsOutputsStaticState { + prf: match options.cred.prf { + PrfCredOptions::None => None, + PrfCredOptions::FalseNoHmac | PrfCredOptions::FalseHmacFalse => { + Some(AuthenticationExtensionsPrfOutputs { enabled: false }) + } + PrfCredOptions::TrueNoHmac | PrfCredOptions::TrueHmacTrue => { + Some(AuthenticationExtensionsPrfOutputs { enabled: true }) + } + }, + }, + }, + DynamicState { + user_verified: true, + backup: Backup::NotEligible, + sign_count: 0, + authenticator_attachment: AuthenticatorAttachment::None, + }, + )?; + opts.start_ceremony()? + .0 + .verify(&rp_id, &authentication, &mut cred, &auth_opts) + .map_err(AggErr::AuthCeremony) + .map(|_| ()) + } + /// Test all, and only, possible `UserNotVerified` errors. + /// 4 * 5 * 3 * 2 * 5 = 600 tests. + /// We ignore this due to how long it takes (around 4 seconds or so). + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[ignore] + #[test] + fn test_uv_required_err() { + const ALL_CRED_PROTECT_OPTIONS: [CredentialProtectionPolicy; 4] = [ + CredentialProtectionPolicy::None, + CredentialProtectionPolicy::UserVerificationOptional, + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList, + CredentialProtectionPolicy::UserVerificationRequired, + ]; + const ALL_PRF_CRED_OPTIONS: [PrfCredOptions; 5] = [ + PrfCredOptions::None, + PrfCredOptions::FalseNoHmac, + PrfCredOptions::FalseHmacFalse, + PrfCredOptions::TrueNoHmac, + PrfCredOptions::TrueHmacTrue, + ]; + const ALL_HMAC_OPTIONS: [HmacSecret; 3] = + [HmacSecret::None, HmacSecret::One, HmacSecret::Two]; + const ALL_UNSOLICIT_OPTIONS: [bool; 2] = [false, true]; + const ALL_NOT_FALSE_PRF_UV_OPTIONS: [PrfUvOptions; 5] = [ + PrfUvOptions::None(true), + PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: None, + ext_info: ExtensionReq::Require, + }), + PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: None, + ext_info: ExtensionReq::Allow, + }), + PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: Some([].as_slice()), + ext_info: ExtensionReq::Require, + }), + PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: Some([].as_slice()), + ext_info: ExtensionReq::Allow, + }), + ]; + for cred_protect in ALL_CRED_PROTECT_OPTIONS { + for prf in ALL_PRF_CRED_OPTIONS { + for hmac in ALL_HMAC_OPTIONS { + for error_unsolicited in ALL_UNSOLICIT_OPTIONS { + for prf_uv in ALL_NOT_FALSE_PRF_UV_OPTIONS { + assert!(validate(TestOptions { + request: TestRequestOptions { + error_unsolicited, + prf_uv, + }, + response: TestResponseOptions { + user_verified: false, + hmac, + }, + cred: TestCredOptions { cred_protect, prf, }, + }).map_or_else(|err| matches!(err, AggErr::AuthCeremony(auth_err) if matches!(auth_err, AuthCeremonyErr::UserNotVerified)), |_| false)); + } + } + } + } + } + } + /// Test all, and only, possible `UserNotVerified` errors. + /// 4 * 5 * 2 * 2 = 80 tests. + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[test] + fn test_forbidden_hmac() { + const ALL_CRED_PROTECT_OPTIONS: [CredentialProtectionPolicy; 4] = [ + CredentialProtectionPolicy::None, + CredentialProtectionPolicy::UserVerificationOptional, + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList, + CredentialProtectionPolicy::UserVerificationRequired, + ]; + const ALL_PRF_CRED_OPTIONS: [PrfCredOptions; 5] = [ + PrfCredOptions::None, + PrfCredOptions::FalseNoHmac, + PrfCredOptions::FalseHmacFalse, + PrfCredOptions::TrueNoHmac, + PrfCredOptions::TrueHmacTrue, + ]; + const ALL_HMAC_OPTIONS: [HmacSecret; 2] = [HmacSecret::One, HmacSecret::Two]; + const ALL_UV_OPTIONS: [bool; 2] = [false, true]; + for cred_protect in ALL_CRED_PROTECT_OPTIONS { + for prf in ALL_PRF_CRED_OPTIONS { + for hmac in ALL_HMAC_OPTIONS { + for user_verified in ALL_UV_OPTIONS { + assert!(validate(TestOptions { + request: TestRequestOptions { + error_unsolicited: true, + prf_uv: PrfUvOptions::None(false), + }, + response: TestResponseOptions { + user_verified, + hmac, + }, + cred: TestCredOptions { cred_protect, prf, }, + }).map_or_else(|err| matches!(err, AggErr::AuthCeremony(auth_err) if matches!(auth_err, AuthCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::ForbiddenHmacSecret))), |_| false)); + } + } + } + } + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[test] + fn test_prf() -> Result<(), AggErr> { + let mut opts = TestOptions { + request: TestRequestOptions { + error_unsolicited: false, + prf_uv: PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: None, + ext_info: ExtensionReq::Allow, + }), + }, + response: TestResponseOptions { + user_verified: true, + hmac: HmacSecret::None, + }, + cred: TestCredOptions { + cred_protect: CredentialProtectionPolicy::None, + prf: PrfCredOptions::None, + }, + }; + validate(opts)?; + opts.request.prf_uv = PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: None, + ext_info: ExtensionReq::Require, + }); + opts.cred.prf = PrfCredOptions::TrueHmacTrue; + 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::MissingHmacSecret))), |_| false)); + opts.response.hmac = HmacSecret::One; + opts.request.prf_uv = PrfUvOptions::Prf(PrfInput { + first: [].as_slice(), + second: None, + ext_info: ExtensionReq::Allow, + }); + opts.cred.prf = PrfCredOptions::TrueNoHmac; + validate(opts)?; + 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)); + opts.response.user_verified = false; + opts.request.prf_uv = PrfUvOptions::None(false); + opts.cred.prf = PrfCredOptions::TrueHmacTrue; + 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::UserNotVerifiedHmacSecret))), |_| false)); + Ok(()) + } } diff --git a/src/request/auth/ser_server_state.rs b/src/request/auth/ser_server_state.rs @@ -1,8 +1,3 @@ -#![expect( - clippy::question_mark_used, - clippy::unseparated_literal_suffix, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] use super::{ super::super::bin::{ Decode, DecodeBuffer, EncDecErr, Encode, EncodeBuffer, EncodeBufferFallible, diff --git a/src/request/register.rs b/src/request/register.rs @@ -6,8 +6,8 @@ use super::{ AuthenticatorAttachment, register::{ Attestation, AttestationFormat, AuthenticatorExtensionOutput, - AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputs, - CredentialProtectionPolicy, Registration, UncompressedPubKey, + ClientExtensionsOutputs, CredentialProtectionPolicy, Registration, + UncompressedPubKey, error::{ExtensionErr, RegCeremonyErr}, }, }, @@ -49,7 +49,7 @@ pub mod error; /// Contains functionality to serialize data to a client. #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] #[cfg(feature = "serde")] -mod ser; +pub(crate) mod ser; /// Contains functionality to (de)serialize [`RegistrationServerState`] to a data store. #[cfg_attr(docsrs, doc(cfg(feature = "serializable_server_state")))] #[cfg(feature = "serializable_server_state")] @@ -64,15 +64,24 @@ pub enum CredProtect { /// Request /// [`userVerificationOptional`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#userverificationoptional) /// but allow any. - UserVerificationOptional(ExtensionInfo), + /// + /// The `bool` corresponds to + /// [`enforceCredentialProtectionPolicy`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#dom-authenticationextensionsclientinputs-enforcecredentialprotectionpolicy). + UserVerificationOptional(bool, ExtensionInfo), /// Request /// [`userVerificationOptionalWithCredentialIDList`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#userverificationoptionalwithcredentialidlist); /// and when enforcing the value, disallow [`CredentialProtectionPolicy::UserVerificationOptional`]. - UserVerificationOptionalWithCredentialIdList(ExtensionInfo), + /// + /// The `bool` corresponds to + /// [`enforceCredentialProtectionPolicy`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#dom-authenticationextensionsclientinputs-enforcecredentialprotectionpolicy). + UserVerificationOptionalWithCredentialIdList(bool, ExtensionInfo), /// Request /// [`userVerificationRequired`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#userverificationrequired); /// and when enforcing the value, only allow [`CredentialProtectionPolicy::UserVerificationRequired`]. - UserVerificationRequired(ExtensionInfo), + /// + /// The `bool` corresponds to + /// [`enforceCredentialProtectionPolicy`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#dom-authenticationextensionsclientinputs-enforcecredentialprotectionpolicy). + UserVerificationRequired(bool, ExtensionInfo), } impl CredProtect { /// Validates `other` is allowed based on `self`. @@ -86,7 +95,7 @@ impl CredProtect { const fn validate(self, other: CredentialProtectionPolicy) -> Result<(), ExtensionErr> { match self { Self::None => Ok(()), - Self::UserVerificationOptional(info) => { + Self::UserVerificationOptional(_, info) => { if matches!(other, CredentialProtectionPolicy::None) { if matches!( info, @@ -100,7 +109,7 @@ impl CredProtect { Ok(()) } } - Self::UserVerificationOptionalWithCredentialIdList(info) => match info { + Self::UserVerificationOptionalWithCredentialIdList(_, info) => match info { ExtensionInfo::RequireEnforceValue => match other { CredentialProtectionPolicy::None => Err(ExtensionErr::MissingCredProtect), CredentialProtectionPolicy::UserVerificationOptional => { @@ -125,7 +134,7 @@ impl CredProtect { } ExtensionInfo::AllowDontEnforceValue => Ok(()), }, - Self::UserVerificationRequired(info) => match info { + Self::UserVerificationRequired(_, info) => match info { ExtensionInfo::RequireEnforceValue => match other { CredentialProtectionPolicy::None => Err(ExtensionErr::MissingCredProtect), CredentialProtectionPolicy::UserVerificationOptional @@ -162,14 +171,14 @@ impl PartialEq for CredProtect { fn eq(&self, other: &Self) -> bool { match *self { Self::None => matches!(other, Self::None), - Self::UserVerificationOptional(info) => { - matches!(*other, Self::UserVerificationOptional(info2) if info == info2) + Self::UserVerificationOptional(enforce, info) => { + matches!(*other, Self::UserVerificationOptional(enforce2, info2) if enforce == enforce2 && info == info2) } - Self::UserVerificationOptionalWithCredentialIdList(info) => { - matches!(*other, Self::UserVerificationOptionalWithCredentialIdList(info2) if info == info2) + Self::UserVerificationOptionalWithCredentialIdList(enforce, info) => { + matches!(*other, Self::UserVerificationOptionalWithCredentialIdList(enforce2, info2) if enforce == enforce2 && info == info2) } - Self::UserVerificationRequired(info) => { - matches!(*other, Self::UserVerificationRequired(info2) if info == info2) + Self::UserVerificationRequired(enforce, info) => { + matches!(*other, Self::UserVerificationRequired(enforce2, info2) if enforce == enforce2 && info == info2) } } } @@ -179,15 +188,21 @@ impl Display for CredProtect { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match *self { Self::None => f.write_str("do not sent a credProtect request"), - Self::UserVerificationOptional(info) => { - write!(f, "request user verification optional and {info}") + Self::UserVerificationOptional(enforce, info) => { + write!( + f, + "request user verification optional with enforcement {enforce} and {info}" + ) } - Self::UserVerificationOptionalWithCredentialIdList(info) => write!( + Self::UserVerificationOptionalWithCredentialIdList(enforce, info) => write!( f, - "user verification optional with credential ID list and {info}" + "user verification optional with credential ID list with enforcement {enforce} and {info}" ), - Self::UserVerificationRequired(info) => { - write!(f, "user verification required and {info}") + Self::UserVerificationRequired(enforce, info) => { + write!( + f, + "user verification required with enforcement {enforce} and {info}" + ) } } } @@ -196,19 +211,50 @@ impl Display for CredProtect { /// as defined in RFC 8266. /// /// Note [string truncation](https://www.w3.org/TR/webauthn-3/#sctn-strings-truncation) is allowed, so one may -/// want to enforce their own length requirements in the event [`Self::MAX_LEN`] is too long. +/// want to enforce [`Self::RECOMMENDED_MAX_LEN`]. #[derive(Clone, Debug)] pub struct Nickname<'a>(Cow<'a, str>); -impl Nickname<'_> { +impl<'a> Nickname<'a> { /// The maximum allowed length. pub const MAX_LEN: usize = 1023; + /// The recommended maximum length to allow. + pub const RECOMMENDED_MAX_LEN: usize = 64; /// Returns a `Nickname` that consumes `self`. When `self` owns the data, the data is simply moved; /// when the data is borrowed, then it is cloned into an owned instance. #[inline] #[must_use] - pub fn into_owned<'a>(self) -> Nickname<'a> { + pub fn into_owned<'b>(self) -> Nickname<'b> { Nickname(Cow::Owned(self.0.into_owned())) } + /// Same as [`Self::with_max_len`] except the length must not exceed [`Self::RECOMMENDED_MAX_LEN`] instead of + /// [`Self::MAX_LEN`]. + /// + /// # Errors + /// + /// Errors iff `value` violates [RFC 8266 § 2.3](https://www.rfc-editor.org/rfc/rfc8266.html#section-2.3) + /// or the resulting length exceeds [`Self::RECOMMENDED_MAX_LEN`]. + #[inline] + pub fn with_recommended_len<'b: 'a>(value: Cow<'b, str>) -> Result<Self, NicknameErr> { + precis_profiles::Nickname::new() + .enforce(value) + .map_err(|_e| NicknameErr::Rfc8266) + .and_then(|val| { + if val.len() <= Self::RECOMMENDED_MAX_LEN { + Ok(Self(val)) + } else { + Err(NicknameErr::Len) + } + }) + } + /// Same as [`Self::try_from`]. + /// # Errors + /// + /// Errors iff `value` violates [RFC 8266 § 2.3](https://www.rfc-editor.org/rfc/rfc8266.html#section-2.3) + /// or the resulting length exceeds [`Self::MAX_LEN`]. + #[inline] + pub fn with_max_len<'b: 'a>(value: Cow<'b, str>) -> Result<Self, NicknameErr> { + Self::try_from(value) + } } impl AsRef<str> for Nickname<'_> { #[inline] @@ -222,6 +268,15 @@ impl Borrow<str> for Nickname<'_> { self.0.as_ref() } } +impl<'a: 'b, 'b> From<&'a Nickname<'_>> for Nickname<'b> { + #[inline] + fn from(value: &'a Nickname<'_>) -> Self { + match value.0 { + Cow::Borrowed(val) => Self(Cow::Borrowed(val)), + Cow::Owned(ref val) => Self(Cow::Borrowed(val.as_str())), + } + } +} impl<'a: 'b, 'b> From<Nickname<'a>> for Cow<'b, str> { #[inline] fn from(value: Nickname<'a>) -> Self { @@ -280,24 +335,92 @@ impl TryFrom<String> for Nickname<'_> { Self::try_from(Cow::Owned(value)) } } +impl PartialEq<Nickname<'_>> for Nickname<'_> { + #[inline] + fn eq(&self, other: &Nickname<'_>) -> bool { + self.0 == other.0 + } +} +impl PartialEq<&Nickname<'_>> for Nickname<'_> { + #[inline] + fn eq(&self, other: &&Nickname<'_>) -> bool { + *self == **other + } +} +impl PartialEq<Nickname<'_>> for &Nickname<'_> { + #[inline] + fn eq(&self, other: &Nickname<'_>) -> bool { + **self == *other + } +} +impl Eq for Nickname<'_> {} +impl Hash for Nickname<'_> { + #[inline] + fn hash<H: Hasher>(&self, state: &mut H) { + self.0.hash(state); + } +} +impl PartialOrd<Nickname<'_>> for Nickname<'_> { + #[inline] + fn partial_cmp(&self, other: &Nickname<'_>) -> Option<Ordering> { + self.0.partial_cmp(&other.0) + } +} +impl Ord for Nickname<'_> { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + self.0.cmp(&other.0) + } +} /// String returned from the /// [UsernameCasePreserved Enforcement rule](https://www.rfc-editor.org/rfc/rfc8265#section-3.4.3) as defined in /// RFC 8265. /// /// Note [string truncation](https://www.w3.org/TR/webauthn-3/#sctn-strings-truncation) is allowed, so one may -/// want to enforce their own length requirements in the event [`Self::MAX_LEN`] is too long. +/// want to enforce [`Self::RECOMMENDED_MAX_LEN`]. #[derive(Clone, Debug)] pub struct Username<'a>(Cow<'a, str>); -impl Username<'_> { +impl<'a> Username<'a> { /// The maximum allowed length. pub const MAX_LEN: usize = 1023; + /// The recommended maximum length to allow. + pub const RECOMMENDED_MAX_LEN: usize = 64; /// Returns a `Username` that consumes `self`. When `self` owns the data, the data is simply moved; /// when the data is borrowed, then it is cloned into an owned instance. #[inline] #[must_use] - pub fn into_owned<'a>(self) -> Username<'a> { + pub fn into_owned<'b>(self) -> Username<'b> { Username(Cow::Owned(self.0.into_owned())) } + /// Same as [`Self::with_max_len`] except the length must not exceed [`Self::RECOMMENDED_MAX_LEN`] instead of + /// [`Self::MAX_LEN`]. + /// + /// # Errors + /// + /// Errors iff `value` violates [RFC 8265 § 3.4.3](https://www.rfc-editor.org/rfc/rfc8265.html#section-3.4.3) + /// or the resulting length exceeds [`Self::RECOMMENDED_MAX_LEN`]. + #[inline] + pub fn with_recommended_len<'b: 'a>(value: Cow<'b, str>) -> Result<Self, UsernameErr> { + UsernameCasePreserved::default() + .enforce(value) + .map_err(|_e| UsernameErr::Rfc8265) + .and_then(|val| { + if val.len() <= Self::RECOMMENDED_MAX_LEN { + Ok(Self(val)) + } else { + Err(UsernameErr::Len) + } + }) + } + /// Same as [`Self::try_from`]. + /// # Errors + /// + /// Errors iff `value` violates [RFC 8265 § 3.4.3](https://www.rfc-editor.org/rfc/rfc8265.html#section-3.4.3) + /// or the resulting length exceeds [`Self::MAX_LEN`]. + #[inline] + pub fn with_max_len<'b: 'a>(value: Cow<'b, str>) -> Result<Self, UsernameErr> { + Self::try_from(value) + } } impl AsRef<str> for Username<'_> { #[inline] @@ -365,6 +488,52 @@ impl<'a: 'b, 'b> From<Username<'a>> for Cow<'b, str> { value.0 } } +impl<'a: 'b, 'b> From<&'a Username<'_>> for Username<'b> { + #[inline] + fn from(value: &'a Username<'_>) -> Self { + match value.0 { + Cow::Borrowed(val) => Self(Cow::Borrowed(val)), + Cow::Owned(ref val) => Self(Cow::Borrowed(val.as_str())), + } + } +} +impl PartialEq<Username<'_>> for Username<'_> { + #[inline] + fn eq(&self, other: &Username<'_>) -> bool { + self.0 == other.0 + } +} +impl PartialEq<&Username<'_>> for Username<'_> { + #[inline] + fn eq(&self, other: &&Username<'_>) -> bool { + *self == **other + } +} +impl PartialEq<Username<'_>> for &Username<'_> { + #[inline] + fn eq(&self, other: &Username<'_>) -> bool { + **self == *other + } +} +impl Eq for Username<'_> {} +impl Hash for Username<'_> { + #[inline] + fn hash<H: Hasher>(&self, state: &mut H) { + self.0.hash(state); + } +} +impl PartialOrd<Username<'_>> for Username<'_> { + #[inline] + fn partial_cmp(&self, other: &Username<'_>) -> Option<Ordering> { + self.0.partial_cmp(&other.0) + } +} +impl Ord for Username<'_> { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + self.0.cmp(&other.0) + } +} /// [`COSEAlgorithmIdentifier`](https://www.w3.org/TR/webauthn-3/#typedefdef-cosealgorithmidentifier). /// /// Note the order of variants is the following: @@ -375,15 +544,19 @@ impl<'a: 'b, 'b> From<Username<'a>> for Cow<'b, str> { /// that contains `Self::Eddsa` will prioritize it over all others. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum CoseAlgorithmIdentifier { - /// [EdDSA](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) with the added requirement that - /// Ed25519 be used for `crv` parameter [per the spec](https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier). + /// [EdDSA](https://www.iana.org/assignments/cose/cose.xhtml#algorithms). + /// + /// Note that Ed25519 must be used for the `crv` parameter + /// [per the spec](https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier). Eddsa, - /// [ES256](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) with the added requirements that - /// P-256 be used for `crv` parameter and the uncompressed form must be used + /// [ES256](https://www.iana.org/assignments/cose/cose.xhtml#algorithms). + /// + /// Note the uncompressed form must be used and P-256 must be used for the `crv` parameter /// [per the spec](https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier). Es256, - /// [ES384](https://www.iana.org/assignments/cose/cose.xhtml#algorithms) with the added requirements that - /// P-384 be used for `crv` parameter and the uncompressed form must be used + /// [ES384](https://www.iana.org/assignments/cose/cose.xhtml#algorithms). + /// + /// Note the uncompressed form must be used and P-384 must be used for the `crv` parameter /// [per the spec](https://www.w3.org/TR/webauthn-3/#sctn-alg-identifier). Es384, /// [RS256](https://www.iana.org/assignments/cose/cose.xhtml#algorithms). @@ -416,7 +589,7 @@ impl PartialEq<CoseAlgorithmIdentifier> for &CoseAlgorithmIdentifier { #[derive(Clone, Copy, Debug)] pub struct CoseAlgorithmIdentifiers(u8); impl CoseAlgorithmIdentifiers { - /// Contains all [`CoseAlgorithmIdentifiers`]. + /// Contains all [`CoseAlgorithmIdentifier`]s. pub const ALL: Self = Self(0) .add(CoseAlgorithmIdentifier::Eddsa) .add(CoseAlgorithmIdentifier::Es256) @@ -472,6 +645,53 @@ impl PartialEq for CoseAlgorithmIdentifiers { self.0 == other.0 } } +/// `newtype` of `u8` bound inclusively between [`Self::MIN`] and [`Self::MAX`]. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct FourToSixtyThree(u8); +impl FourToSixtyThree { + /// Minimum inner value. + const MIN_INNER: u8 = 4; + /// Maximum inner value. + const MAX_INNER: u8 = 63; + /// Minimum value. + pub const MIN: Self = Self(Self::MIN_INNER); + /// Maximum value. + pub const MAX: Self = Self(Self::MAX_INNER); + /// Returns `Self` iff `val` is inclusively between [`Self::MIN`] and [`Self::MAX`]. + #[inline] + #[must_use] + pub const fn new(val: u8) -> Option<Self> { + match val { + Self::MIN_INNER..=Self::MAX_INNER => Some(Self(val)), + _ => None, + } + } + /// Returns the contained value. + #[inline] + #[must_use] + pub const fn value(self) -> u8 { + self.0 + } +} +impl Display for FourToSixtyThree { + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +impl From<FourToSixtyThree> for u8 { + #[inline] + fn from(value: FourToSixtyThree) -> Self { + value.0 + } +} +impl Default for FourToSixtyThree { + /// Returns [`Self::MIN`]. + #[inline] + fn default() -> Self { + Self::MIN + } +} /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client. #[derive(Clone, Copy, Debug, Default)] pub struct Extension { @@ -496,25 +716,28 @@ pub struct Extension { /// When the value is enforced, that corresponds to /// [`minPinLength`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-minpinlength-extension) /// in [`extensions`](https://www.w3.org/TR/webauthn-3/#authdata-extensions) set to a value at least as large - /// as the contained `u8`. - pub min_pin_length: Option<(u8, ExtensionInfo)>, + /// as the contained `FourToSixtyThree`. + pub min_pin_length: Option<(FourToSixtyThree, ExtensionInfo)>, /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension). /// /// When the value is enforced, that corresponds to - /// [`enabled`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfoutputs-enabled) and - /// [`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) - /// in [`extensions`](https://www.w3.org/TR/webauthn-3/#authdata-extensions) set to `true`. In contrast - /// [`results`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfoutputs-results) must not exist, - /// be `null`, or be an + /// [`enabled`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfoutputs-enabled) set to `true`. + /// In contrast [`results`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfoutputs-results) + /// must not exist, be `null`, or be an /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues) - /// such that [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first) is `null` and - /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second) does not exist or is - /// `null`. This is to ensure the decrypted outputs stay on the client. + /// such that [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first) is `null` + /// and [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second) does not + /// exist or is `null`. This is to ensure the decrypted outputs stay on the client. /// /// Note for /// [CTAP 2.2](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-hmac-secret-extension) /// this is only used to instruct the authenticator to create the necessary `hmac-secret` since it does not - /// currently support PRF evaluation at creation time. This also requires [`UserVerificationRequirement::Required`]. + /// currently support PRF evaluation at creation time. This also requires + /// [`UserVerificationRequirement::Required`]. + /// + /// While many authenticators will be able to use the `prf` extension during authentication when this is not + /// passed during registration, it is recommended to still pass this during registration in the event the + /// authenticator does require it during registration. pub prf: Option<ExtensionInfo>, } impl Extension { @@ -564,15 +787,18 @@ impl Extension { Err(ExtensionErr::ForbiddenMinPinLength) } else { // Pretend to set `minPinLength`, so we can check `prf`. - self.min_pin_length = Some((0, ExtensionInfo::RequireEnforceValue)); + self.min_pin_length = + Some((FourToSixtyThree::MIN, ExtensionInfo::RequireEnforceValue)); self.validate_unsolicited(client_ext, auth_ext) } } else if !matches!(auth_ext.cred_protect, CredentialProtectionPolicy::None) { Err(ExtensionErr::ForbiddenCredProtect) } else { // Pretend to set `credProtect`, so we can check `minPinLength` and `prf` extensions. - self.cred_protect = - CredProtect::UserVerificationOptional(ExtensionInfo::RequireEnforceValue); + self.cred_protect = CredProtect::UserVerificationOptional( + false, + ExtensionInfo::RequireEnforceValue, + ); self.validate_unsolicited(client_ext, auth_ext) } } else if client_ext.cred_props.is_some() { @@ -638,10 +864,7 @@ impl Extension { | ExtensionInfo::RequireDontEnforceValue ) { if client_ext.prf.is_some() { - auth_ext - .hmac_secret - .ok_or(ExtensionErr::MissingHmacSecret) - .map(|_| ()) + Ok(()) } else { Err(ExtensionErr::MissingPrf) } @@ -732,8 +955,28 @@ pub const USER_HANDLE_MAX_LEN: usize = 64; pub const USER_HANDLE_MIN_LEN: usize = 1; /// A [user handle](https://www.w3.org/TR/webauthn-3/#user-handle) that is made up of /// [`USER_HANDLE_MIN_LEN`]–[`USER_HANDLE_MAX_LEN`] bytes. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct UserHandle<const LEN: usize>([u8; LEN]); +impl<const LEN: usize> UserHandle<LEN> { + /// Returns the contained data as a `slice`. + #[inline] + #[must_use] + pub const fn as_slice(&self) -> &[u8] { + self.0.as_slice() + } + /// Returns the contained data as a shared reference to the array. + #[inline] + #[must_use] + pub const fn as_array(&self) -> &[u8; LEN] { + &self.0 + } + /// Returns the contained data. + #[inline] + #[must_use] + pub const fn into_array(self) -> [u8; LEN] { + self.0 + } +} /// Implements [`Default`] for [`UserHandle`] of array of length of the passed `usize` literal. /// /// Only [`USER_HANDLE_MIN_LEN`]–[`USER_HANDLE_MAX_LEN`] inclusively are allowed to be passed. @@ -789,13 +1032,37 @@ where impl<const LEN: usize> AsRef<[u8]> for UserHandle<LEN> { #[inline] fn as_ref(&self) -> &[u8] { - self.0.as_slice() + self.as_slice() } } impl<const LEN: usize> Borrow<[u8]> for UserHandle<LEN> { #[inline] fn borrow(&self) -> &[u8] { - self.0.as_slice() + self.as_slice() + } +} +impl<const LEN: usize> PartialEq<&Self> for UserHandle<LEN> { + #[inline] + fn eq(&self, other: &&Self) -> bool { + *self == **other + } +} +impl<const LEN: usize> PartialEq<UserHandle<LEN>> for &UserHandle<LEN> { + #[inline] + fn eq(&self, other: &UserHandle<LEN>) -> bool { + **self == *other + } +} +impl<const LEN: usize> From<UserHandle<LEN>> for [u8; LEN] { + #[inline] + fn from(value: UserHandle<LEN>) -> Self { + value.into_array() + } +} +impl<'a: 'b, 'b, const LEN: usize> From<&'a UserHandle<LEN>> for &'b [u8; LEN] { + #[inline] + fn from(value: &'a UserHandle<LEN>) -> Self { + value.as_array() } } /// `UserHandle` that is based on the [spec recommendation](https://www.w3.org/TR/webauthn-3/#user-handle). @@ -805,6 +1072,71 @@ pub type UserHandle64 = UserHandle<USER_HANDLE_MAX_LEN>; /// While not the recommended size like [`UserHandle64`], 16 bytes is common for many deployments since /// it's the same size as [Universally Unique IDentifiers (UUIDs)](https://www.rfc-editor.org/rfc/rfc9562). pub type UserHandle16 = UserHandle<16>; +impl UserHandle16 { + /// Same as [`Self::new`] except 6 bits of metadata is encoded to conform with + /// [UUID Version 4](https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::UserHandle16; + /// assert!(UserHandle16::new_uuid_v4().is_uuid_v4()); + /// ``` + #[inline] + #[must_use] + pub fn new_uuid_v4() -> Self { + let mut this = Self::new(); + // The first 4 bits of the 6th octet (0-based index) represents the UUID version (i.e., 4). + // We first 0-out the version bits retaining the other 4 bits, then set the version bits to 4. + this.0[6] = (this.0[6] & 0x0F) | 0x40; + // The first 2 bits of the 8th octet (0-based index) represents the UUID variant (i.e., 8,9,A,B) + // which is defined to be 2. + // We first 0-out the variant bits retaining the other 6 bits, then set the variant bits to 2. + this.0[8] = (this.0[8] & 0x3F) | 0x80; + this + } + /// Returns `true` iff `self` is a valid + /// [UUID Version 4](https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::UserHandle16; + /// assert!(UserHandle16::new_uuid_v4().is_uuid_v4()); + /// let mut user = UserHandle16::new_uuid_v4().into_array(); + /// user[6] = 255; + /// # #[cfg(feature = "custom")] + /// assert!(!UserHandle16::from(user).is_uuid_v4()); + /// ``` + #[inline] + #[must_use] + pub const fn is_uuid_v4(&self) -> bool { + // The first 4 bits of the 6th octet (0-based index) represents the UUID version (i.e., 4). + // The first 2 bits of the 8th octet (0-based index) represents the UUID variant (i.e., 8,9,A,B) which + // is defined to be 2. + self.0[6] >> 4 == 0x4 && self.0[8] >> 6 == 0x2 + } + /// Returns `Some` containing `uuid_v4` iff `uuid_v4` is a valid + /// [UUID Version 4](https://www.rfc-editor.org/rfc/rfc9562#name-uuid-version-4). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::register::UserHandle16; + /// let mut user = UserHandle16::new_uuid_v4().into_array(); + /// assert!(UserHandle16::from_uuid_v4(user).is_some()); + /// user[8] = 255; + /// assert!(UserHandle16::from_uuid_v4(user).is_none()); + /// ``` + #[cfg_attr(docsrs, doc(cfg(feature = "custom")))] + #[cfg(feature = "custom")] + #[inline] + #[must_use] + pub fn from_uuid_v4(uuid_v4: [u8; 16]) -> Option<Self> { + let this = Self(uuid_v4); + this.is_uuid_v4().then_some(this) + } +} /// [The `PublicKeyCredentialUserEntity`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialuserentity) /// sent to the client. #[derive(Clone, Debug)] @@ -851,6 +1183,12 @@ impl<'a: 'b, 'b, const LEN: usize> From<&'a UserHandle<LEN>> } } } +/// `PublicKeyCredentialUserEntity` based on a [`UserHandle64`]. +pub type PublicKeyCredentialUserEntity64<'name, 'display_name, 'id> = + PublicKeyCredentialUserEntity<'name, 'display_name, 'id, USER_HANDLE_MAX_LEN>; +/// `PublicKeyCredentialUserEntity` based on a [`UserHandle16`]. +pub type PublicKeyCredentialUserEntity16<'name, 'display_name, 'id> = + PublicKeyCredentialUserEntity<'name, 'display_name, 'id, 16>; /// [`ResidentKeyRequirement`](https://www.w3.org/TR/webauthn-3/#enumdef-residentkeyrequirement) sent to the client. #[derive(Clone, Copy, Debug)] pub enum ResidentKeyRequirement { @@ -1140,7 +1478,7 @@ const fn validate_options_helper( Err(CreationOptionsErr::PrfWithoutUserVerification) } else if matches!( extensions.cred_protect, - CredProtect::UserVerificationRequired(_) + CredProtect::UserVerificationRequired(_, _) ) { Err(CreationOptionsErr::CredProtectRequiredWithoutUserVerification) } else { @@ -1195,7 +1533,7 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> /// /// 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. - /// [`Extension::cred_protect`] with [`CredProtect::UserVerificationRequired`] and + /// [`Extension::cred_protect`] with [`CredProtect::UserVerificationRequired`] with `false` and /// [`ExtensionInfo::AllowEnforceValue`] is used. [`Self::mediation`] is /// [`CredentialMediationRequirement::Optional`]. /// @@ -1241,6 +1579,7 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> extensions: Extension { cred_props: None, cred_protect: CredProtect::UserVerificationRequired( + false, ExtensionInfo::AllowEnforceValue, ), min_pin_length: None, @@ -1283,7 +1622,9 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> /// /// 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`]. [`Self::mediation`] is [`CredentialMediationRequirement::Optional`]. + /// is [`ExtensionReq::Allow`]. [`Extension::cred_protect`] is + /// [`CredProtect::UserVerificationOptionalWithCredentialIdList`] with `false` and + /// [`ExtensionInfo::AllowEnforceValue`]. [`Self::mediation`] is [`CredentialMediationRequirement::Optional`]. /// /// Note some authenticators require user verification during credential registration (e.g., /// [CTAP 2.0 authenticators](https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-client-to-authenticator-protocol-v2.0-id-20180227.html#authenticatorMakeCredential)). @@ -1328,7 +1669,10 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> let mut opts = Self::passkey(rp_id, user, exclude_credentials); opts.authenticator_selection = AuthenticatorSelectionCriteria::second_factor(); opts.extensions.cred_props = Some(ExtensionReq::Allow); - opts.extensions.cred_protect = CredProtect::None; + opts.extensions.cred_protect = CredProtect::UserVerificationOptionalWithCredentialIdList( + false, + ExtensionInfo::AllowEnforceValue, + ); opts } /// Convenience function for [`Self::second_factor`] passing an empty `Vec`. @@ -1415,6 +1759,18 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> }) } } +/// `PublicKeyCredentialCreationOptions` based on a [`UserHandle64`]. +pub type PublicKeyCredentialCreationOptions64<'rp_id, 'user_name, 'user_display_name, 'user_id> = + PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + USER_HANDLE_MAX_LEN, + >; +/// `PublicKeyCredentialCreationOptions` based on a [`UserHandle16`]. +pub type PublicKeyCredentialCreationOptions16<'rp_id, 'user_name, 'user_display_name, 'user_id> = + PublicKeyCredentialCreationOptions<'rp_id, 'user_name, 'user_display_name, 'user_id, 16>; /// Container of a [`PublicKeyCredentialCreationOptions`] that has been used to start the registration ceremony. /// This gets sent to the client ASAP. #[derive(Debug)] @@ -1469,6 +1825,12 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> &self.0 } } +/// `RegistrationClientState` based on a [`UserHandle64`]. +pub type RegistrationClientState64<'rp_id, 'user_name, 'user_display_name, 'user_id> = + RegistrationClientState<'rp_id, 'user_name, 'user_display_name, 'user_id, USER_HANDLE_MAX_LEN>; +/// `RegistrationClientState` based on a [`UserHandle16`]. +pub type RegistrationClientState16<'rp_id, 'user_name, 'user_display_name, 'user_id> = + RegistrationClientState<'rp_id, 'user_name, 'user_display_name, 'user_id, 16>; /// Additional verification options to perform in [`RegistrationServerState::verify`]. #[derive(Clone, Copy, Debug)] pub struct RegistrationVerificationOptions<'origins, 'top_origins, O, T> { @@ -1655,11 +2017,10 @@ impl<const USER_LEN: usize> RegistrationServerState<USER_LEN> { StaticState { credential_public_key: attested_credential_data .credential_public_key, - extensions: - AuthenticatorExtensionOutputStaticState { - cred_protect: extensions.cred_protect, - hmac_secret: extensions.hmac_secret, - }, + extensions: extensions.into(), + client_extension_results: response + .client_extension_results + .into(), }, DynamicState { user_verified: flags.user_verified, @@ -1682,7 +2043,8 @@ impl<const USER_LEN: usize> RegistrationServerState<USER_LEN> { aaguid: attested_credential_data.aaguid, extensions: extensions.into(), client_extension_results: response - .client_extension_results, + .client_extension_results + .into(), resident_key: self .authenticator_selection .resident_key, @@ -1721,10 +2083,6 @@ impl<const USER_LEN: usize> TimedCeremony for RegistrationServerState<USER_LEN> fn expiration(&self) -> SystemTime { self.expiration } - #[inline] - fn sent_challenge(&self) -> SentChallenge { - self.challenge - } } impl<const USER_LEN: usize> Ceremony<USER_LEN, false> for RegistrationServerState<USER_LEN> { type R = Registration; @@ -1788,41 +2146,119 @@ impl<const USER_LEN: usize> Ord for RegistrationServerState<USER_LEN> { self.challenge.cmp(&other.challenge) } } +/// `RegistrationServerState` based on a [`UserHandle64`]. +pub type RegistrationServerState64 = RegistrationServerState<USER_HANDLE_MAX_LEN>; +/// `RegistrationServerState` based on a [`UserHandle16`]. +pub type RegistrationServerState16 = RegistrationServerState<16>; #[cfg(test)] mod tests { + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] + use super::{ + super::{super::AggErr, ExtensionInfo}, + Challenge, CredProtect, FourToSixtyThree, PublicKeyCredentialCreationOptions, RpId, + UserHandle, + }; #[cfg(all(feature = "custom", feature = "serializable_server_state"))] use super::{ super::{ + super::bin::{Decode as _, Encode as _}, + AsciiDomain, + }, + Extension, PublicKeyCredentialUserEntity, RegistrationServerState, + }; + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + use super::{ + super::{ super::{ - AggErr, - bin::{Decode as _, Encode as _}, + CredentialErr, + response::register::{ + AuthenticationExtensionsPrfOutputs, AuthenticatorAttestation, + ClientExtensionsOutputs, CredentialPropertiesOutput, + CredentialProtectionPolicy, + }, }, - AsciiDomain, + AuthTransports, }, - Challenge, CredProtect, Extension, ExtensionInfo, PublicKeyCredentialCreationOptions, - PublicKeyCredentialUserEntity, RegistrationServerState, RpId, UserHandle, + 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", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] use rsa::sha2::{Digest as _, Sha256}; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_UINT: u8 = 0b000_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_NEG: u8 = 0b001_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_BYTES: u8 = 0b010_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_TEXT: u8 = 0b011_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_MAP: u8 = 0b101_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[cfg(all( + feature = "custom", + any( + feature = "serializable_server_state", + not(any(feature = "bin", feature = "serde")) + ) + ))] const CBOR_SIMPLE: u8 = 0b111_00000; - #[cfg(all(feature = "custom", feature = "serializable_server_state"))] + #[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")) + ) + ))] const CBOR_TRUE: u8 = CBOR_SIMPLE | 21; #[test] #[cfg(all(feature = "custom", feature = "serializable_server_state"))] - fn ed25519_reg_ser() -> Result<(), AggErr> { + fn eddsa_reg_ser() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let id = UserHandle::from([0; 1]); let mut opts = PublicKeyCredentialCreationOptions::passkey( @@ -1837,8 +2273,15 @@ mod tests { opts.challenge = Challenge(0); opts.extensions = Extension { cred_props: None, - cred_protect: CredProtect::UserVerificationRequired(ExtensionInfo::RequireEnforceValue), - min_pin_length: Some((10, ExtensionInfo::RequireEnforceValue)), + cred_protect: CredProtect::UserVerificationRequired( + false, + ExtensionInfo::RequireEnforceValue, + ), + min_pin_length: Some(( + FourToSixtyThree::new(10) + .unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), + ExtensionInfo::RequireEnforceValue, + )), prf: Some(ExtensionInfo::RequireEnforceValue), }; let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); @@ -2156,4 +2599,701 @@ mod tests { ); Ok(()) } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestResponseOptions { + user_verified: bool, + cred_protect: CredentialProtectionPolicy, + prf: Option<bool>, + hmac: Option<bool>, + min_pin: Option<FourToSixtyThree>, + cred_props: Option<Option<bool>>, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + enum PrfUvOptions { + /// `true` iff `UserVerificationRequirement::Required` should be used; otherwise + /// `UserVerificationRequirement::Preferred` is used. + None(bool), + Prf(ExtensionInfo), + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestRequestOptions { + error_unsolicited: bool, + protect: CredProtect, + prf_uv: PrfUvOptions, + props: Option<ExtensionReq>, + pin: Option<(FourToSixtyThree, ExtensionInfo)>, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + #[derive(Clone, Copy)] + struct TestOptions { + request: TestRequestOptions, + response: TestResponseOptions, + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn generate_client_data_json() -> Vec<u8> { + let mut json = Vec::with_capacity(256); + json.extend_from_slice(br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.as_slice()); + json + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn generate_attestation_object(options: TestResponseOptions) -> Vec<u8> { + let mut attestation_object = Vec::with_capacity(256); + attestation_object.extend_from_slice( + [ + CBOR_MAP | 3, + CBOR_TEXT | 3, + b'f', + b'm', + b't', + CBOR_TEXT | 4, + b'n', + b'o', + b'n', + b'e', + CBOR_TEXT | 7, + b'a', + b't', + b't', + b'S', + b't', + b'm', + b't', + CBOR_MAP, + CBOR_TEXT | 8, + b'a', + b'u', + b't', + b'h', + b'D', + b'a', + b't', + b'a', + CBOR_BYTES | 24, + // Length. + 113 + if matches!(options.cred_protect, CredentialProtectionPolicy::None) { + if options.hmac.is_some() { + 14 + options.min_pin.map_or(0, |_| 14) + } else { + options.min_pin.map_or(0, |_| 15) + } + } else { + 14 + options.hmac.map_or(0, |_| 13) + options.min_pin.map_or(0, |_| 14) + }, + // 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). + 0b0100_0001 + | if options.user_verified { + 0b0000_0100 + } else { + 0b0000_0000 + } + | if matches!(options.cred_protect, CredentialProtectionPolicy::None) + && options.hmac.is_none() + && options.min_pin.is_none() + { + 0 + } else { + 0b1000_0000 + }, + // 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. + 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(), + ); + attestation_object[30..62] + .copy_from_slice(Sha256::digest("example.com".as_bytes()).as_slice()); + if matches!(options.cred_protect, CredentialProtectionPolicy::None) { + if options.hmac.is_some() { + if options.min_pin.is_some() { + attestation_object.push(CBOR_MAP | 2); + } else { + attestation_object.push(CBOR_MAP | 1); + } + } else if options.min_pin.is_some() { + attestation_object.push(CBOR_MAP | 1); + } + } else { + attestation_object.extend_from_slice( + [ + CBOR_MAP | 1 + u8::from(options.hmac.is_some()) + u8::from(options.min_pin.is_some()), + // CBOR text of length 11. + 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', + match options.cred_protect { CredentialProtectionPolicy::None => unreachable!("bug"), CredentialProtectionPolicy::UserVerificationOptional => 1, CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList => 2, CredentialProtectionPolicy::UserVerificationRequired => 3, }, + ].as_slice() + ) + } + options.hmac.map(|h| { + attestation_object.extend_from_slice( + [ + // 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', + if h { CBOR_TRUE } else { CBOR_FALSE }, + ] + .as_slice(), + ); + }); + options.min_pin.map(|p| { + assert!(p.value() <= 23, "bug"); + attestation_object.extend_from_slice( + [ + // CBOR text of length 12. + 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 | p.value(), + ] + .as_slice(), + ); + }); + attestation_object + } + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn validate(options: TestOptions) -> Result<(), AggErr> { + let rp_id = RpId::Domain("example.com".to_owned().try_into()?); + let registration = Registration::new( + AuthenticatorAttestation::new( + generate_client_data_json(), + generate_attestation_object(options.response), + AuthTransports::NONE, + ), + AuthenticatorAttachment::None, + ClientExtensionsOutputs { + cred_props: options + .response + .cred_props + .map(|rk| CredentialPropertiesOutput { rk }), + prf: options + .response + .prf + .map(|enabled| AuthenticationExtensionsPrfOutputs { enabled }), + }, + ); + let reg_opts = RegistrationVerificationOptions::<'static, 'static, &str, &str> { + allowed_origins: [].as_slice(), + allowed_top_origins: None, + backup_requirement: BackupReq::None, + error_on_unsolicited_extensions: options.request.error_unsolicited, + require_authenticator_attachment: false, + #[cfg(feature = "serde_relaxed")] + client_data_json_relaxed: false, + }; + let user = UserHandle::from([0; 1]); + let mut opts = + PublicKeyCredentialCreationOptions::first_passkey_with_blank_user_info(&rp_id, &user); + opts.challenge = Challenge(0); + opts.authenticator_selection.user_verification = UserVerificationRequirement::Preferred; + match options.request.prf_uv { + PrfUvOptions::None(required) => { + if required + || matches!( + options.request.protect, + CredProtect::UserVerificationRequired(_, _) + ) + { + opts.authenticator_selection.user_verification = + UserVerificationRequirement::Required; + } + } + PrfUvOptions::Prf(info) => { + opts.authenticator_selection.user_verification = + UserVerificationRequirement::Required; + opts.extensions.prf = Some(info); + } + } + opts.extensions.cred_protect = options.request.protect; + opts.extensions.cred_props = options.request.props; + opts.extensions.min_pin_length = options.request.pin; + opts.start_ceremony()? + .0 + .verify(&rp_id, &registration, &reg_opts) + .map_err(AggErr::RegCeremony) + .map(|_| ()) + } + /// Test all, and only, possible `UserNotVerified` errors. + /// 4 * 3 * 3 * 2 * 13 * 5 * 3 * 5 * 4 * 4 = 1,123,200 tests. + /// We ignore this due to how long it takes (around 30 seconds or so). + #[test] + #[ignore] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn test_uv_required_err() { + const ALL_CRED_PROTECTION_OPTIONS: [CredentialProtectionPolicy; 4] = [ + CredentialProtectionPolicy::None, + CredentialProtectionPolicy::UserVerificationOptional, + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList, + CredentialProtectionPolicy::UserVerificationRequired, + ]; + const ALL_PRF_OPTIONS: [Option<bool>; 3] = [None, Some(false), Some(true)]; + const ALL_HMAC_OPTIONS: [Option<bool>; 3] = [None, Some(false), Some(true)]; + const ALL_UNSOLICIT_OPTIONS: [bool; 2] = [false, true]; + const ALL_CRED_PROTECT_OPTIONS: [CredProtect; 13] = [ + CredProtect::None, + CredProtect::UserVerificationOptional(false, ExtensionInfo::RequireEnforceValue), + CredProtect::UserVerificationOptional(true, ExtensionInfo::RequireDontEnforceValue), + CredProtect::UserVerificationOptional(false, ExtensionInfo::AllowEnforceValue), + CredProtect::UserVerificationOptional(true, ExtensionInfo::AllowDontEnforceValue), + CredProtect::UserVerificationOptionalWithCredentialIdList( + false, + ExtensionInfo::RequireEnforceValue, + ), + CredProtect::UserVerificationOptionalWithCredentialIdList( + true, + ExtensionInfo::RequireDontEnforceValue, + ), + CredProtect::UserVerificationOptionalWithCredentialIdList( + false, + ExtensionInfo::AllowEnforceValue, + ), + CredProtect::UserVerificationOptionalWithCredentialIdList( + true, + ExtensionInfo::AllowDontEnforceValue, + ), + CredProtect::UserVerificationRequired(false, ExtensionInfo::RequireEnforceValue), + CredProtect::UserVerificationRequired(true, ExtensionInfo::RequireDontEnforceValue), + CredProtect::UserVerificationRequired(false, ExtensionInfo::AllowEnforceValue), + CredProtect::UserVerificationRequired(true, ExtensionInfo::AllowDontEnforceValue), + ]; + const ALL_NOT_FALSE_PRF_UV_OPTIONS: [PrfUvOptions; 5] = [ + PrfUvOptions::None(true), + PrfUvOptions::Prf(ExtensionInfo::RequireEnforceValue), + PrfUvOptions::Prf(ExtensionInfo::RequireDontEnforceValue), + PrfUvOptions::Prf(ExtensionInfo::AllowEnforceValue), + PrfUvOptions::Prf(ExtensionInfo::AllowDontEnforceValue), + ]; + const ALL_PROPS_OPTIONS: [Option<ExtensionReq>; 3] = + [None, Some(ExtensionReq::Require), Some(ExtensionReq::Allow)]; + const ALL_PIN_OPTIONS: [Option<(FourToSixtyThree, ExtensionInfo)>; 5] = [ + None, + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::RequireEnforceValue, + )), + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::RequireDontEnforceValue, + )), + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::AllowEnforceValue, + )), + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::AllowDontEnforceValue, + )), + ]; + const ALL_CRED_PROPS_OPTIONS: [Option<Option<bool>>; 4] = + [None, Some(None), Some(Some(false)), Some(Some(true))]; + const ALL_MIN_PIN_OPTIONS: [Option<FourToSixtyThree>; 4] = [ + None, + Some(FourToSixtyThree::MIN), + Some(FourToSixtyThree::new(5).unwrap()), + Some(FourToSixtyThree::new(6).unwrap()), + ]; + for cred_protect in ALL_CRED_PROTECTION_OPTIONS { + for prf in ALL_PRF_OPTIONS { + for hmac in ALL_HMAC_OPTIONS { + for cred_props in ALL_CRED_PROPS_OPTIONS { + for min_pin in ALL_MIN_PIN_OPTIONS { + for error_unsolicited in ALL_UNSOLICIT_OPTIONS { + for protect in ALL_CRED_PROTECT_OPTIONS { + for prf_uv in ALL_NOT_FALSE_PRF_UV_OPTIONS { + for props in ALL_PROPS_OPTIONS { + for pin in ALL_PIN_OPTIONS { + assert!(validate(TestOptions { + request: TestRequestOptions { + error_unsolicited, + protect, + prf_uv, + props, + pin, + }, + response: TestResponseOptions { + user_verified: false, + hmac, + cred_protect, + prf, + min_pin, + cred_props, + }, + }).map_or_else(|err| matches!(err, AggErr::RegCeremony(reg_err) if matches!(reg_err, RegCeremonyErr::UserNotVerified)), |_| false)); + } + } + } + } + } + } + } + } + } + } + } + /// Test all, and only, possible `ForbiddenCredProps` errors. + /// 4 * 3 * 3 * 2 * 13 * 6 * 5 * 3 * 4 = 336,960 + /// - + /// 4 * 3 * 3 * 4 * 6 * 5 * 3 * 4 = 51,840 + /// - + /// 4 * 3 * 3 * 13 * 5 * 5 * 3 * 4 = 140,400 + /// + + /// 4 * 3 * 3 * 4 * 5 * 5 * 3 * 4 = 43,200 + /// = + /// 187,920 total tests. + /// We ignore this due to how long it takes (around 6 seconds or so). + #[test] + #[ignore] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn test_forbidden_cred_props() { + const ALL_CRED_PROTECTION_OPTIONS: [CredentialProtectionPolicy; 4] = [ + CredentialProtectionPolicy::None, + CredentialProtectionPolicy::UserVerificationOptional, + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList, + CredentialProtectionPolicy::UserVerificationRequired, + ]; + const ALL_PRF_OPTIONS: [Option<bool>; 3] = [None, Some(false), Some(true)]; + const ALL_HMAC_OPTIONS: [Option<bool>; 3] = [None, Some(false), Some(true)]; + const ALL_UV_OPTIONS: [bool; 2] = [false, true]; + const ALL_CRED_PROTECT_OPTIONS: [CredProtect; 13] = [ + CredProtect::None, + CredProtect::UserVerificationOptional(false, ExtensionInfo::RequireEnforceValue), + CredProtect::UserVerificationOptional(true, ExtensionInfo::RequireDontEnforceValue), + CredProtect::UserVerificationOptional(false, ExtensionInfo::AllowEnforceValue), + CredProtect::UserVerificationOptional(true, ExtensionInfo::AllowDontEnforceValue), + CredProtect::UserVerificationOptionalWithCredentialIdList( + false, + ExtensionInfo::RequireEnforceValue, + ), + CredProtect::UserVerificationOptionalWithCredentialIdList( + true, + ExtensionInfo::RequireDontEnforceValue, + ), + CredProtect::UserVerificationOptionalWithCredentialIdList( + false, + ExtensionInfo::AllowEnforceValue, + ), + CredProtect::UserVerificationOptionalWithCredentialIdList( + true, + ExtensionInfo::AllowDontEnforceValue, + ), + CredProtect::UserVerificationRequired(false, ExtensionInfo::RequireEnforceValue), + CredProtect::UserVerificationRequired(true, ExtensionInfo::RequireDontEnforceValue), + CredProtect::UserVerificationRequired(false, ExtensionInfo::AllowEnforceValue), + CredProtect::UserVerificationRequired(true, ExtensionInfo::AllowDontEnforceValue), + ]; + const ALL_PRF_UV_OPTIONS: [PrfUvOptions; 6] = [ + PrfUvOptions::None(false), + PrfUvOptions::None(true), + PrfUvOptions::Prf(ExtensionInfo::RequireEnforceValue), + PrfUvOptions::Prf(ExtensionInfo::RequireDontEnforceValue), + PrfUvOptions::Prf(ExtensionInfo::AllowEnforceValue), + PrfUvOptions::Prf(ExtensionInfo::AllowDontEnforceValue), + ]; + const ALL_PIN_OPTIONS: [Option<(FourToSixtyThree, ExtensionInfo)>; 5] = [ + None, + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::RequireEnforceValue, + )), + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::RequireDontEnforceValue, + )), + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::AllowEnforceValue, + )), + Some(( + FourToSixtyThree::new(5).unwrap(), + ExtensionInfo::AllowDontEnforceValue, + )), + ]; + const ALL_NON_EMPTY_CRED_PROPS_OPTIONS: [Option<Option<bool>>; 3] = + [Some(None), Some(Some(false)), Some(Some(true))]; + const ALL_MIN_PIN_OPTIONS: [Option<FourToSixtyThree>; 4] = [ + None, + Some(FourToSixtyThree::MIN), + Some(FourToSixtyThree::new(5).unwrap()), + Some(FourToSixtyThree::new(6).unwrap()), + ]; + for cred_protect in ALL_CRED_PROTECTION_OPTIONS { + for prf in ALL_PRF_OPTIONS { + for hmac in ALL_HMAC_OPTIONS { + for cred_props in ALL_NON_EMPTY_CRED_PROPS_OPTIONS { + for min_pin in ALL_MIN_PIN_OPTIONS { + for user_verified in ALL_UV_OPTIONS { + for protect in ALL_CRED_PROTECT_OPTIONS { + for prf_uv in ALL_PRF_UV_OPTIONS { + for pin in ALL_PIN_OPTIONS { + if user_verified + || (!matches!( + protect, + CredProtect::UserVerificationRequired(_, _) + ) && matches!(prf_uv, PrfUvOptions::None(uv) if !uv)) + { + assert!(validate(TestOptions { + request: TestRequestOptions { + error_unsolicited: true, + protect, + prf_uv, + props: None, + pin, + }, + response: TestResponseOptions { + user_verified, + hmac, + cred_protect, + prf, + min_pin, + cred_props, + }, + }).map_or_else(|err| matches!(err, AggErr::RegCeremony(reg_err) if matches!(reg_err, RegCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::ForbiddenCredProps))), |_| false)); + } + } + } + } + } + } + } + } + } + } + } + #[test] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn test_prf() -> Result<(), AggErr> { + let mut opts = TestOptions { + request: TestRequestOptions { + error_unsolicited: false, + protect: CredProtect::None, + prf_uv: PrfUvOptions::Prf(ExtensionInfo::RequireEnforceValue), + props: None, + pin: None, + }, + response: TestResponseOptions { + user_verified: true, + hmac: None, + cred_protect: CredentialProtectionPolicy::None, + prf: Some(true), + min_pin: None, + cred_props: None, + }, + }; + validate(opts)?; + opts.response.prf = Some(false); + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::InvalidPrfValue))), |_| false)); + opts.response.hmac = Some(false); + opts.response.prf = Some(true); + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::InvalidHmacSecretValue))), |_| false)); + opts.request.prf_uv = PrfUvOptions::Prf(ExtensionInfo::AllowDontEnforceValue); + opts.response.hmac = Some(true); + opts.response.prf = None; + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Credential(cred_err) if matches!(cred_err, CredentialErr::HmacSecretWithoutPrf))), |_| false)); + opts.response.hmac = Some(false); + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Credential(cred_err) if matches!(cred_err, CredentialErr::HmacSecretWithoutPrf))), |_| false)); + opts.response.prf = Some(true); + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Credential(cred_err) if matches!(cred_err, CredentialErr::PrfWithoutHmacSecret))), |_| false)); + opts.response.prf = Some(false); + validate(opts)?; + opts.request.prf_uv = PrfUvOptions::None(false); + opts.response.user_verified = false; + opts.response.hmac = None; + opts.response.prf = Some(true); + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Credential(cred_err) if matches!(cred_err, CredentialErr::PrfWithoutUserVerified))), |_| false)); + opts.response.prf = None; + validate(opts)?; + Ok(()) + } + #[test] + #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] + fn test_cred_protect() -> Result<(), AggErr> { + let mut opts = TestOptions { + request: TestRequestOptions { + error_unsolicited: false, + protect: CredProtect::UserVerificationRequired( + false, + ExtensionInfo::RequireEnforceValue, + ), + prf_uv: PrfUvOptions::None(false), + props: None, + pin: None, + }, + response: TestResponseOptions { + user_verified: true, + hmac: None, + cred_protect: CredentialProtectionPolicy::UserVerificationRequired, + prf: None, + min_pin: None, + cred_props: None, + }, + }; + validate(opts)?; + opts.response.cred_protect = + CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList; + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Extension(ext_err) if matches!(ext_err, ExtensionErr::InvalidCredProtectValue(CredProtect::UserVerificationRequired(false, ExtensionInfo::RequireEnforceValue), CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList)))), |_| false)); + opts.request.protect = + CredProtect::UserVerificationOptional(true, ExtensionInfo::RequireEnforceValue); + opts.response.user_verified = false; + opts.response.cred_protect = CredentialProtectionPolicy::UserVerificationRequired; + assert!(validate(opts).map_or_else(|e| matches!(e, AggErr::RegCeremony(err) if matches!(err, RegCeremonyErr::Credential(cred_err) if matches!(cred_err, CredentialErr::CredProtectUserVerificationRequiredWithoutUserVerified))), |_| false)); + Ok(()) + } } diff --git a/src/request/register/error.rs b/src/request/register/error.rs @@ -16,7 +16,8 @@ pub enum NicknameErr { /// Error returned when the [Nickname Enforcement rule](https://www.rfc-editor.org/rfc/rfc8266#section-2.3) /// fails. Rfc8266, - /// Error returned when the length of the transformed string would exceed [`Nickname::MAX_LEN`]. + /// Error returned when the length of the transformed string would exceed [`Nickname::MAX_LEN`] or + /// [`Nickname::RECOMMENDED_MAX_LEN`]. Len, } impl Display for NicknameErr { @@ -35,14 +36,15 @@ pub enum UsernameErr { /// Error returned when the /// [UsernameCasePreserved Enforcement rule](https://www.rfc-editor.org/rfc/rfc8265#section-3.4.3) fails. Rfc8265, - /// Error returned when the length of the transformed string would exceed [`Nickname::MAX_LEN`]. + /// Error returned when the length of the transformed string would exceed [`Username::MAX_LEN`] or + /// [`Username::RECOMMENDED_MAX_LEN`]. Len, } impl Display for UsernameErr { #[inline] fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { f.write_str(match *self { - Self::Rfc8265 => "nickname does not conform to RFC 8265", + Self::Rfc8265 => "username does not conform to RFC 8265", Self::Len => "length of username is too long", }) } diff --git a/src/request/register/ser.rs b/src/request/register/ser.rs @@ -2,7 +2,7 @@ extern crate alloc; use super::{ AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, CoseAlgorithmIdentifier, CoseAlgorithmIdentifiers, CredProtect, CrossPlatformHint, Extension, - ExtensionInfo, Hint, Nickname, PlatformHint, PublicKeyCredentialUserEntity, + Hint, Nickname, PlatformHint, PublicKeyCredentialUserEntity, RegistrationClientState, ResidentKeyRequirement, RpId, UserHandle, Username, }; use alloc::borrow::Cow; @@ -80,15 +80,12 @@ impl Serialize for CoseAlgorithmIdentifier { .serialize_struct("PublicKeyCredentialParameters", 2) .and_then(|mut ser| { ser.serialize_field("type", "public-key").and_then(|()| { - ser.serialize_field( - "alg", - &match *self { - Self::Eddsa => EDDSA, - Self::Es256 => ES256, - Self::Es384 => ES384, - Self::Rs256 => RS256, - }, - ) + ser.serialize_field("alg", &match *self { + Self::Eddsa => EDDSA, + Self::Es256 => ES256, + Self::Es384 => ES384, + Self::Rs256 => RS256, + }) .and_then(|()| ser.end()) }) }) @@ -113,17 +110,17 @@ impl Serialize for CoseAlgorithmIdentifiers { /// # Ok::<_, serde_json::Error>(()) /// ``` #[expect( - clippy::as_conversions, - reason = "u8::count_ones returns a u32, and it's always going to fit in a usize" + clippy::arithmetic_side_effects, + reason = "comment justifies correctness" )] #[inline] fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { - // `self.0.count_ones()` is at most 4 which is guaranteed to be a valid `usize`. + // At most we add `1` four times which clearly cannot overflow or `usize`. serializer - .serialize_seq(Some(self.0.count_ones() as usize)) + .serialize_seq(Some(usize::from(self.contains(CoseAlgorithmIdentifier::Eddsa)) + usize::from(self.contains(CoseAlgorithmIdentifier::Es256)) + usize::from(self.contains(CoseAlgorithmIdentifier::Es384)) + usize::from(self.contains(CoseAlgorithmIdentifier::Es384)))) .and_then(|mut ser| { if self.contains(CoseAlgorithmIdentifier::Eddsa) { ser.serialize_element(&CoseAlgorithmIdentifier::Eddsa) @@ -176,7 +173,8 @@ impl Serialize for PublicKeyCredentialRpEntity<'_> { }) } } -impl<const LEN: usize> Serialize for UserHandle<LEN> { +// We implement this separately from `user_serialize` for proper documentation and example purposes. +impl Serialize for UserHandle<1> { /// Serializes `self` to conform with /// [`id`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialuserentityjson-id). /// @@ -197,10 +195,39 @@ impl<const LEN: usize> Serialize for UserHandle<LEN> { where S: Serializer, { - serializer.serialize_str(BASE64URL_NOPAD.encode(self.0.as_ref()).as_str()) + serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str(self.0.as_slice(), [0; crate::base64url_nopad_len(1).unwrap()].as_mut_slice())) } } -impl<const LEN: usize> Serialize for PublicKeyCredentialUserEntity<'_, '_, '_, LEN> { +/// Implements [`Serialize`] for [`UserHandle`] of array of length of the passed `usize` literal. +/// +/// Only [`USER_HANDLE_MIN_LEN`]–[`USER_HANDLE_MAX_LEN`] inclusively are allowed to be passed. +macro_rules! user_serialize { + ( $( $x:literal),* ) => { + $( +impl Serialize for UserHandle<$x> { + /// See [`UserHandle::serialize`]. + #[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.as_slice(), [0; crate::base64url_nopad_len($x).unwrap()].as_mut_slice())) + } +} + )* + }; +} +// MUST only pass `2`–[`USER_HANDLE_MAX_LEN`] inclusively. +user_serialize!( + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, + 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, + 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64 +); +impl<const LEN: usize> Serialize for PublicKeyCredentialUserEntity<'_, '_, '_, LEN> +where + UserHandle<LEN>: Serialize, +{ /// Serializes `self` to conform with /// [`PublicKeyCredentialUserEntityJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialuserentityjson). /// @@ -448,18 +475,18 @@ impl Serialize for Extension { /// /// ``` /// # use webauthn_rp::request::{ - /// # register::{CredProtect, Extension}, + /// # register::{CredProtect, Extension, FourToSixtyThree}, /// # ExtensionInfo, ExtensionReq, /// # }; /// assert_eq!(serde_json::to_string(&Extension::default())?, r#"{}"#); /// assert_eq!( /// serde_json::to_string(&Extension { /// cred_props: Some(ExtensionReq::Allow), - /// cred_protect: CredProtect::UserVerificationRequired(ExtensionInfo::RequireEnforceValue), - /// min_pin_length: Some((16, ExtensionInfo::AllowDontEnforceValue)), + /// cred_protect: CredProtect::UserVerificationRequired(false, ExtensionInfo::RequireEnforceValue), + /// min_pin_length: Some((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::AllowDontEnforceValue)), /// prf: Some(ExtensionInfo::AllowEnforceValue) /// })?, - /// r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":true,"minPinLength":true,"prf":{}}"# + /// r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{}}"# /// ); /// # Ok::<_, serde_json::Error>(()) /// ``` @@ -497,7 +524,7 @@ impl Serialize for Extension { if matches!(self.cred_protect, CredProtect::None) { Ok(()) } else { - let ext_info; + let enforce_policy; // [`credProtect`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-credProtect-extension) // is serialized by serializing its fields directly and not as a map of fields. ser.serialize_field( @@ -506,18 +533,18 @@ impl Serialize for Extension { CredProtect::None => unreachable!( "Extensions is incorrectly serializing credProtect" ), - CredProtect::UserVerificationOptional(info) => { - ext_info = info; + CredProtect::UserVerificationOptional(enforce, _) => { + enforce_policy = enforce; "userVerificationOptional" } CredProtect::UserVerificationOptionalWithCredentialIdList( - info, + enforce, _, ) => { - ext_info = info; + enforce_policy = enforce; "userVerificationOptionalWithCredentialIDList" } - CredProtect::UserVerificationRequired(info) => { - ext_info = info; + CredProtect::UserVerificationRequired(enforce, _) => { + enforce_policy = enforce; "userVerificationRequired" } }, @@ -525,11 +552,7 @@ impl Serialize for Extension { .and_then(|()| { ser.serialize_field( "enforceCredentialProtectionPolicy", - &matches!( - ext_info, - ExtensionInfo::RequireEnforceValue - | ExtensionInfo::AllowEnforceValue - ), + &enforce_policy, ) }) } @@ -546,7 +569,10 @@ impl Serialize for Extension { }) } } -impl<const USER_LEN: usize> Serialize for RegistrationClientState<'_, '_, '_, '_, USER_LEN> { +impl<'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> Serialize for RegistrationClientState<'_, 'user_name, 'user_display_name, 'user_id, USER_LEN> +where + PublicKeyCredentialUserEntity<'user_name, 'user_display_name, 'user_id, USER_LEN>: Serialize, +{ /// Serializes `self` to conform with /// [`PublicKeyCredentialCreationOptionsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptionsjson). /// @@ -558,7 +584,7 @@ impl<const USER_LEN: usize> Serialize for RegistrationClientState<'_, '_, '_, '_ /// # use webauthn_rp::{ /// # request::{ /// # register::{ - /// # UserHandle64, AuthenticatorAttachmentReq, PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle + /// # FourToSixtyThree, UserHandle64, AuthenticatorAttachmentReq, PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle /// # }, /// # AsciiDomain, ExtensionInfo, Hint, RpId, PublicKeyCredentialDescriptor, Credentials, UserVerificationRequirement, /// # }, @@ -585,7 +611,7 @@ impl<const USER_LEN: usize> Serialize for RegistrationClientState<'_, '_, '_, '_ /// let user_handle = UserHandle64::new(); /// let mut options = PublicKeyCredentialCreationOptions::passkey(&rp_id, PublicKeyCredentialUserEntity { name: "pierre.de.fermat".try_into()?, id: &user_handle, display_name: Some("Pierre de Fermat".try_into()?) }, creds); /// options.authenticator_selection.authenticator_attachment = AuthenticatorAttachmentReq::None(Hint::SecurityKey); - /// options.extensions.min_pin_length = Some((16, ExtensionInfo::RequireEnforceValue)); + /// options.extensions.min_pin_length = Some((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::RequireEnforceValue)); /// # #[cfg(all(feature = "bin", feature = "custom"))] /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap(); /// let json = serde_json::json!({ @@ -639,7 +665,7 @@ impl<const USER_LEN: usize> Serialize for RegistrationClientState<'_, '_, '_, '_ /// ], /// "extensions":{ /// "credentialProtectionPolicy":"userVerificationRequired", - /// "enforceCredentialProtectionPolicy":true, + /// "enforceCredentialProtectionPolicy":false, /// "minPinLength":true /// } /// }).to_string(); @@ -877,7 +903,7 @@ impl<'de> Deserialize<'de> for CoseAlgorithmIdentifier { match v { EDDSA => Ok(CoseAlgorithmIdentifier::Eddsa), ES256 => Ok(CoseAlgorithmIdentifier::Es256), - ES384 => Ok(CoseAlgorithmIdentifier::Es384), + ES384=> Ok(CoseAlgorithmIdentifier::Es384), RS256 => Ok(CoseAlgorithmIdentifier::Rs256), _ => Err(E::invalid_value( Unexpected::Signed(i64::from(v)), diff --git a/src/request/register/ser_server_state.rs b/src/request/register/ser_server_state.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::unseparated_literal_suffix, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] use super::{ super::super::bin::{ Decode, DecodeBuffer, EncDecErr, Encode, EncodeBuffer, EncodeBufferFallible as _, @@ -133,16 +129,19 @@ impl EncodeBuffer for CredProtect { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { match *self { Self::None => 0u8.encode_into_buffer(buffer), - Self::UserVerificationOptional(info) => { + Self::UserVerificationOptional(enforce, info) => { 1u8.encode_into_buffer(buffer); + enforce.encode_into_buffer(buffer); info.encode_into_buffer(buffer); } - Self::UserVerificationOptionalWithCredentialIdList(info) => { + Self::UserVerificationOptionalWithCredentialIdList(enforce, info) => { 2u8.encode_into_buffer(buffer); + enforce.encode_into_buffer(buffer); info.encode_into_buffer(buffer); } - Self::UserVerificationRequired(info) => { + Self::UserVerificationRequired(enforce, info) => { 3u8.encode_into_buffer(buffer); + enforce.encode_into_buffer(buffer); info.encode_into_buffer(buffer); } } @@ -153,10 +152,18 @@ impl<'a> DecodeBuffer<'a> for CredProtect { fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { u8::decode_from_buffer(data).and_then(|val| match val { 0 => Ok(Self::None), - 1 => ExtensionInfo::decode_from_buffer(data).map(Self::UserVerificationOptional), - 2 => ExtensionInfo::decode_from_buffer(data) - .map(Self::UserVerificationOptionalWithCredentialIdList), - 3 => ExtensionInfo::decode_from_buffer(data).map(Self::UserVerificationRequired), + 1 => bool::decode_from_buffer(data).and_then(|enforce| { + ExtensionInfo::decode_from_buffer(data) + .map(|info| Self::UserVerificationOptional(enforce, info)) + }), + 2 => bool::decode_from_buffer(data).and_then(|enforce| { + ExtensionInfo::decode_from_buffer(data) + .map(|info| Self::UserVerificationOptionalWithCredentialIdList(enforce, info)) + }), + 3 => bool::decode_from_buffer(data).and_then(|enforce| { + ExtensionInfo::decode_from_buffer(data) + .map(|info| Self::UserVerificationRequired(enforce, info)) + }), _ => Err(EncDecErr), }) } diff --git a/src/request/ser.rs b/src/request/ser.rs @@ -19,7 +19,6 @@ impl Serialize for Challenge { /// assert_eq!(serde_json::to_string(&Challenge::new())?.len(), 24); /// # Ok::<_, serde_json::Error>(()) /// ``` - #[expect(clippy::unreachable, reason = "when there is a bug, we want to crash")] #[expect( clippy::little_endian_bytes, reason = "SentChallenge::deserialize and Challenge::serialize need to be consistent across architectures" @@ -29,13 +28,10 @@ impl Serialize for Challenge { where S: Serializer, { - let mut data = [0; Self::BASE64_LEN]; - BASE64URL_NOPAD.encode_mut(self.0.to_le_bytes().as_slice(), data.as_mut_slice()); - serializer.serialize_str( - str::from_utf8(data.as_slice()) - // There is a bug, so crash and burn. - .unwrap_or_else(|_| unreachable!("there is a bug in Challenge::serialize")), - ) + serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str( + self.0.to_le_bytes().as_slice(), + [0; Self::BASE64_LEN].as_mut_slice(), + )) } } impl Serialize for RpId { diff --git a/src/response.rs b/src/response.rs @@ -29,12 +29,12 @@ use ser_relaxed::SerdeJsonErr; /// # Examples /// /// ```no_run +/// # use core::convert; /// # use data_encoding::BASE64URL_NOPAD; -/// # #[cfg(not(feature = "serializable_server_state"))] -/// # use webauthn_rp::request::{FixedCapHashSet, InsertResult}; /// # use webauthn_rp::{ +/// # hash::hash_set::FixedCapHashSet, /// # request::{auth::{error::RequestOptionsErr, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, error::AsciiDomainErr, register::{UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, AsciiDomain, BackupReq, RpId}, -/// # response::{auth::{error::AuthCeremonyErr, DiscoverableAuthentication64}, error::CollectedClientDataErr, register::{AuthenticatorExtensionOutputStaticState, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, CompressedPubKey, StaticState}, AuthenticatorAttachment, Backup, CollectedClientData, CredentialId}, +/// # response::{auth::{error::AuthCeremonyErr, DiscoverableAuthentication64}, error::CollectedClientDataErr, register::{AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, CompressedPubKey, StaticState}, AuthenticatorAttachment, Backup, CollectedClientData, CredentialId}, /// # AuthenticatedCredential, CredentialErr /// # }; /// # #[derive(Debug)] @@ -79,15 +79,12 @@ use ser_relaxed::SerdeJsonErr; /// # Self::AuthCeremony(value) /// # } /// # } -/// # #[cfg(not(feature = "serializable_server_state"))] /// 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()?; -/// # #[cfg(not(feature = "serializable_server_state"))] -/// assert!(matches!( -/// ceremonies.insert_or_replace_all_expired(server), -/// InsertResult::Success -/// )); +/// assert!( +/// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) +/// ); /// # #[cfg(feature = "serde")] /// let authentication = serde_json::from_str::<DiscoverableAuthentication64>(get_authentication_json(client).as_str())?; /// # #[cfg(feature = "serde")] @@ -96,7 +93,7 @@ use ser_relaxed::SerdeJsonErr; /// let (static_state, dynamic_state) = get_credential(authentication.raw_id(), &user_handle).ok_or(E::UnknownCredential)?; /// # #[cfg(all(feature = "custom", feature = "serde"))] /// let mut cred = AuthenticatedCredential::new(authentication.raw_id(), &user_handle, static_state, dynamic_state)?; -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom", feature = "serde"))] +/// # #[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())? { /// update_cred(authentication.raw_id(), cred.dynamic_state()); /// } @@ -126,7 +123,7 @@ use ser_relaxed::SerdeJsonErr; /// /// 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)> { /// // ⋮ -/// # Some((StaticState { credential_public_key: CompressedPubKey::Ed25519(Ed25519PubKey::from([0; 32])), extensions: AuthenticatorExtensionOutputStaticState { cred_protect: CredentialProtectionPolicy::UserVerificationRequired, hmac_secret: None, } }, DynamicState { user_verified: true, backup: Backup::NotEligible, sign_count: 1, authenticator_attachment: AuthenticatorAttachment::None })) +/// # 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 })) /// } /// /// Updates the current `DynamicState` associated with `id` in the database to /// /// `dyn_state`. @@ -155,10 +152,10 @@ pub mod error; /// # Examples /// /// ```no_run +/// # use core::convert; /// # use data_encoding::BASE64URL_NOPAD; -/// # #[cfg(not(feature = "serializable_server_state"))] -/// # use webauthn_rp::request::{FixedCapHashSet, InsertResult}; /// # use webauthn_rp::{ +/// # hash::hash_set::FixedCapHashSet, /// # request::{register::{error::CreationOptionsErr, PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationClientState, UserHandle, UserHandle64, USER_HANDLE_MAX_LEN, RegistrationVerificationOptions}, error::AsciiDomainErr, AsciiDomain, PublicKeyCredentialDescriptor, RpId}, /// # response::{register::{error::RegCeremonyErr, Registration}, error::CollectedClientDataErr, CollectedClientData}, /// # RegisteredCredential @@ -197,7 +194,7 @@ pub mod error; /// # Self::RegCeremony(value) /// # } /// # } -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom"))] +/// # #[cfg(feature = "custom")] /// let mut ceremonies = FixedCapHashSet::new(128); /// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// # #[cfg(feature = "custom")] @@ -208,15 +205,14 @@ pub mod error; /// let creds = get_registered_credentials(user_handle); /// # #[cfg(feature = "custom")] /// let (server, client) = PublicKeyCredentialCreationOptions::passkey(&rp_id, user, creds).start_ceremony()?; -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom"))] -/// assert!(matches!( -/// ceremonies.insert_or_replace_all_expired(server), -/// InsertResult::Success -/// )); +/// # #[cfg(feature = "custom")] +/// assert!( +/// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) +/// ); /// # #[cfg(all(feature = "serde_relaxed", feature = "custom"))] /// let registration = serde_json::from_str::<Registration>(get_registration_json(client).as_str())?; /// let ver_opts = RegistrationVerificationOptions::<&str, &str>::default(); -/// # #[cfg(all(not(feature = "serializable_server_state"), feature = "custom", feature = "serde_relaxed"))] +/// # #[cfg(all(feature = "custom", feature = "serde_relaxed"))] /// 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")] @@ -592,6 +588,18 @@ impl<T: Hash> Hash for CredentialId<T> { self.0.hash(state); } } +impl<T: PartialOrd<T2>, T2: PartialOrd<T>> PartialOrd<CredentialId<T>> for CredentialId<T2> { + #[inline] + fn partial_cmp(&self, other: &CredentialId<T>) -> Option<Ordering> { + self.0.partial_cmp(&other.0) + } +} +impl<T: Ord> Ord for CredentialId<T> { + #[inline] + fn cmp(&self, other: &Self) -> Ordering { + self.0.cmp(&other.0) + } +} // We define a separate type to ensure challenges sent to the client are always randomly generated; // otherwise one could deserialize arbitrary data into a `Challenge`. /// Copy of [`Challenge`] sent back from the client. diff --git a/src/response/auth.rs b/src/response/auth.rs @@ -3,18 +3,17 @@ use self::{ super::ser_relaxed::{RelaxedClientDataJsonParser, SerdeJsonErr}, ser_relaxed::{AuthenticationRelaxed, CustomAuthentication}, }; -#[cfg(all(doc, feature = "serde_relaxed"))] -use super::super::request::FixedCapHashSet; #[cfg(doc)] use super::super::{ AuthenticatedCredential, RegisteredCredential, StaticState, + hash::hash_set::FixedCapHashSet, request::{ Challenge, auth::{ - DiscoverableAuthenticationServerState, NonDiscoverableAuthenticationServerState, - PublicKeyCredentialRequestOptions, + CredentialSpecificExtension, DiscoverableAuthenticationServerState, Extension, + NonDiscoverableAuthenticationServerState, PublicKeyCredentialRequestOptions, }, - register::{UserHandle16, UserHandle64}, + register::{self, UserHandle16, UserHandle64}, }, }; use super::{ @@ -22,7 +21,7 @@ use super::{ AuthData, AuthDataContainer, AuthExtOutput, AuthRespErr, AuthResponse, AuthenticatorAttachment, CborSuccess, ClientDataJsonParser as _, CollectedClientData, CredentialId, Flag, FromCbor, LimitedVerificationParser, ParsedAuthData, Response, SentChallenge, - auth::error::{AuthenticatorDataErr, AuthenticatorExtensionOutputErr}, + auth::error::{AuthenticatorDataErr, AuthenticatorExtensionOutputErr, MissingUserHandleErr}, cbor, error::CollectedClientDataErr, register::CompressedPubKey, @@ -48,13 +47,35 @@ pub(super) mod ser; #[cfg(feature = "serde_relaxed")] pub mod ser_relaxed; /// [`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). +/// +/// This is only relevant when [`Extension::prf`] or [`CredentialSpecificExtension::prf`] is `Some` and for +/// authenticators that implement [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension) on top of +/// `hmac-secret`. +/// +/// Note while many authenticators that implement `prf` don't require `prf` to have been sent during registration +/// (i.e., [`register::Extension::prf`]), it is recommended to do so for those authenticators that do require it. #[derive(Clone, Copy, Debug)] pub enum HmacSecret { /// No `hmac-secret` response. + /// + /// Either [`Extension::prf`] was not sent, the credential is not PRF-capable, or the authenticator does not use + /// 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. None, /// One encrypted `hmac-secret`. + /// + /// [`Extension::prf`] was sent with one PRF input for a PRF-capable credential whose authenticator implements + /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension) on top of 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. One, /// Two encrypted `hmac-secret`s. + /// + /// [`Extension::prf`] was sent with two PRF inputs for a PRF-capable credential whose authenticator implements + /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension) on top of 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. Two, } impl FromCbor<'_> for HmacSecret { @@ -299,7 +320,7 @@ impl<const USER_LEN: usize, const DISCOVERABLE: bool> /// [`clientDataJSON`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorresponse-clientdatajson). #[inline] #[must_use] - pub fn client_data_json(&self) -> &[u8] { + pub const fn client_data_json(&self) -> &[u8] { self.client_data_json.as_slice() } /// [`authenticatorData`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorassertionresponse-authenticatordata). @@ -320,7 +341,7 @@ impl<const USER_LEN: usize, const DISCOVERABLE: bool> /// [`signature`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorassertionresponse-signature). #[inline] #[must_use] - pub fn signature(&self) -> &[u8] { + pub const fn signature(&self) -> &[u8] { self.signature.as_slice() } /// Constructs an instance of `Self` with the contained data. @@ -533,6 +554,39 @@ pub type DiscoverableAuthenticatorAssertion<const USER_LEN: usize> = /// `AuthenticatorAssertion` with an optional `UserHandle`. pub type NonDiscoverableAuthenticatorAssertion<const USER_LEN: usize> = AuthenticatorAssertion<USER_LEN, false>; +impl<const USER_LEN: usize> From<DiscoverableAuthenticatorAssertion<USER_LEN>> + for NonDiscoverableAuthenticatorAssertion<USER_LEN> +{ + #[inline] + fn from(value: DiscoverableAuthenticatorAssertion<USER_LEN>) -> Self { + Self { + client_data_json: value.client_data_json, + authenticator_data_and_c_data_hash: value.authenticator_data_and_c_data_hash, + signature: value.signature, + user_handle: value.user_handle, + } + } +} +impl<const USER_LEN: usize> TryFrom<NonDiscoverableAuthenticatorAssertion<USER_LEN>> + for DiscoverableAuthenticatorAssertion<USER_LEN> +{ + type Error = MissingUserHandleErr; + #[inline] + fn try_from( + value: NonDiscoverableAuthenticatorAssertion<USER_LEN>, + ) -> Result<Self, MissingUserHandleErr> { + if value.user_handle.is_some() { + Ok(Self { + client_data_json: value.client_data_json, + authenticator_data_and_c_data_hash: value.authenticator_data_and_c_data_hash, + signature: value.signature, + user_handle: value.user_handle, + }) + } else { + Err(MissingUserHandleErr) + } + } +} /// [`PublicKeyCredential`](https://www.w3.org/TR/webauthn-3/#iface-pkcredential) for authentication ceremonies. #[expect( clippy::field_scoped_visibility_modifiers, @@ -673,6 +727,33 @@ pub type DiscoverableAuthentication64 = Authentication<USER_HANDLE_MAX_LEN, true pub type DiscoverableAuthentication16 = Authentication<16, true>; /// `Authentication` with an optional [`UserHandle`]. pub type NonDiscoverableAuthentication<const USER_LEN: usize> = Authentication<USER_LEN, false>; +impl<const USER_LEN: usize> From<DiscoverableAuthentication<USER_LEN>> + for NonDiscoverableAuthentication<USER_LEN> +{ + #[inline] + fn from(value: DiscoverableAuthentication<USER_LEN>) -> Self { + Self { + raw_id: value.raw_id, + response: value.response.into(), + authenticator_attachment: value.authenticator_attachment, + } + } +} +impl<const USER_LEN: usize> TryFrom<NonDiscoverableAuthentication<USER_LEN>> + for DiscoverableAuthentication<USER_LEN> +{ + type Error = MissingUserHandleErr; + #[inline] + fn try_from( + value: NonDiscoverableAuthentication<USER_LEN>, + ) -> Result<Self, MissingUserHandleErr> { + value.response.try_into().map(|response| Self { + raw_id: value.raw_id, + response, + authenticator_attachment: value.authenticator_attachment, + }) + } +} /// `Authentication` with an optional [`UserHandle64`]. pub type NonDiscoverableAuthentication64 = Authentication<USER_HANDLE_MAX_LEN, false>; /// `Authentication` with an optional [`UserHandle16`]. diff --git a/src/response/auth/error.rs b/src/response/auth/error.rs @@ -1,9 +1,8 @@ #[cfg(feature = "serde_relaxed")] use super::super::SerdeJsonErr; use super::super::{ - super::{CredentialErr, CredentialId}, - AuthRespErr, AuthenticatorDataErr as AuthDataErr, CeremonyErr, CollectedClientDataErr, - PubKeyErr, RpId, + super::CredentialId, AuthRespErr, AuthenticatorDataErr as AuthDataErr, CeremonyErr, + CollectedClientDataErr, PubKeyErr, RpId, }; #[cfg(doc)] use super::{ @@ -14,16 +13,18 @@ use super::{ BackupReq, UserVerificationRequirement, auth::{ AllowedCredential, AllowedCredentials, AuthenticationVerificationOptions, - DiscoverableAuthenticationServerState, Extension, + CredentialSpecificExtension, DiscoverableAuthenticationServerState, + DiscoverableCredentialRequestOptions, Extension, NonDiscoverableAuthenticationServerState, PublicKeyCredentialRequestOptions, }, }, }, Backup, + register::CredentialProtectionPolicy, }, Authentication, AuthenticatorAssertion, AuthenticatorAttachment, AuthenticatorData, AuthenticatorExtensionOutput, CollectedClientData, CompressedPubKey, Flag, HmacSecret, - Signature, + Signature, UserHandle, }; use core::{ convert::Infallible, @@ -150,28 +151,40 @@ impl Display for OneOrTwo { /// Error in [`AuthCeremonyErr::Extension`]. #[derive(Clone, Copy, Debug)] pub enum ExtensionErr { + /// [`AuthenticatorExtensionOutput::hmac_secret`] was sent from the client, but [`Flag::user_verified`] + /// was `false`. + /// + /// Note this is only possible iff [`PublicKeyCredentialRequestOptions::user_verification`] is not + /// [`UserVerificationRequirement::Required`], [`Extension::prf`] is `None`, + /// [`CredentialSpecificExtension::prf`] is `None`, and + /// [`AuthenticationVerificationOptions::error_on_unsolicited_extensions`] is `false`. + UserNotVerifiedHmacSecret, /// [`AuthenticatorExtensionOutput::hmac_secret`] was sent from the client but was not supposed to be. ForbiddenHmacSecret, - /// [`AuthenticatorExtensionOutput::hmac_secret`] was sent from the client for a credential that is not PRF - /// capable. - HmacSecretForPrfIncapableCred, - /// [`Extension::prf`] was requested, but the required response was not sent back. + /// [`Extension::prf`] was requested for a credential that does not support it. + PrfRequestedForPrfIncapableCred, + /// [`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. MissingHmacSecret, /// [`Extension::prf`] was requested with the first number of PRF inputs, but the second number of /// [`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) - /// outputs were sent. + /// outputs were sent for a PRF-capable credential that is based on the `hmac-secret` extension. InvalidHmacSecretValue(OneOrTwo, OneOrTwo), } impl Display for ExtensionErr { #[inline] fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match *self { + Self::UserNotVerifiedHmacSecret => { + f.write_str("user was not verified but hmac-secret info was sent from the client") + } Self::ForbiddenHmacSecret => { f.write_str("hmac-secret info was sent from the client, but it is not allowed") } - Self::HmacSecretForPrfIncapableCred => f.write_str( - "hmac-secret info was sent from the client for a PRF-incapable credential", - ), + Self::PrfRequestedForPrfIncapableCred => { + f.write_str("prf extension was requested for a credential that is not PRF-capable") + } Self::MissingHmacSecret => f.write_str("hmac-secret was not sent from the client"), Self::InvalidHmacSecretValue(sent, recv) => write!( f, @@ -242,8 +255,12 @@ pub enum AuthCeremonyErr { /// [`AllowedCredentials`] is empty (i.e., a discoverable request was issued), but /// [`AuthenticatorAssertion::user_handle`] was [`None`]. MissingUserHandle, - /// Variant returned when [`AuthenticatedCredential`] cannot be updated due to invalid state. - Credential(CredentialErr), + /// [`Flag::user_verified`] was `false`, but the credential has + /// [`CredentialProtectionPolicy::UserVerificationRequired`]. + UserNotVerifiedCredProtectRequired, + /// [`DiscoverableCredentialRequestOptions`] was sent but the credential has + /// [`CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIdList`]. + DiscoverableCredProtectCredentialIdList, } impl Display for AuthCeremonyErr { #[inline] @@ -279,7 +296,8 @@ impl Display for AuthCeremonyErr { Self::CredentialIdMismatch => f.write_str("the credential ID does not match"), Self::NoMatchingAllowedCredential => f.write_str("none of the credentials used to start the non-discoverable request have the same Credential ID as the credential used to finish the ceremony"), Self::MissingUserHandle => f.write_str("the credential used to finish the ceremony did not have a user handle despite a discoverable request being issued"), - Self::Credential(err) => err.fmt(f), + Self::UserNotVerifiedCredProtectRequired => f.write_str("the credential requires user verification, but the user was not verified"), + Self::DiscoverableCredProtectCredentialIdList => f.write_str("the credential requires user verification or to be used for non-discoverable requests, but a discoverable request was used and the user was not verified"), } } } @@ -296,3 +314,13 @@ pub struct UnknownCredentialOptions<'rp, 'cred> { /// [`credentialId`](https://www.w3.org/TR/webauthn-3/#dictdef-unknowncredentialoptions-credentialid). pub credential_id: CredentialId<&'cred [u8]>, } +/// Error when a [`UserHandle`] does not exist that is required to. +#[derive(Debug)] +pub struct MissingUserHandleErr; +impl Display for MissingUserHandleErr { + #[inline] + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str("user handle does not exist") + } +} +impl Error for MissingUserHandleErr {} diff --git a/src/response/auth/ser.rs b/src/response/auth/ser.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::question_mark_used, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] use super::{ super::{ super::response::ser::{Base64DecodedVal, PublicKeyCredential}, diff --git a/src/response/auth/ser_relaxed.rs b/src/response/auth/ser_relaxed.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::question_mark_used, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] #[cfg(doc)] use super::super::{Challenge, CredentialId}; use super::{ diff --git a/src/response/register.rs b/src/response/register.rs @@ -3,15 +3,12 @@ use self::{ super::ser_relaxed::{RelaxedClientDataJsonParser, SerdeJsonErr}, ser_relaxed::{CustomRegistration, RegistrationRelaxed}, }; -#[cfg(all(doc, feature = "serde_relaxed"))] -use super::super::request::FixedCapHashSet; #[cfg(all(doc, feature = "bin"))] +use super::super::bin::{Decode, Encode}; +#[cfg(feature = "bin")] +use super::register::bin::{AaguidOwned, MetadataOwned}; use super::{ - super::bin::{Decode, Encode}, - register::bin::MetadataOwned, -}; -use super::{ - super::request::register::ResidentKeyRequirement, + super::request::register::{FourToSixtyThree, ResidentKeyRequirement}, AuthData, AuthDataContainer, AuthExtOutput, AuthRespErr, AuthResponse, AuthTransports, AuthenticatorAttachment, Backup, CborSuccess, ClientDataJsonParser as _, CollectedClientData, CredentialId, Flag, FromCbor, LimitedVerificationParser, ParsedAuthData, Response, @@ -28,8 +25,9 @@ use super::{ use super::{ super::{ AuthenticatedCredential, RegisteredCredential, + hash::hash_set::FixedCapHashSet, request::{ - BackupReq, Challenge, + BackupReq, Challenge, UserVerificationRequirement, auth::{AuthenticationVerificationOptions, PublicKeyCredentialRequestOptions}, register::{Extension, RegistrationServerState}, }, @@ -105,7 +103,7 @@ pub struct AuthenticatorExtensionOutput { /// [`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). pub hmac_secret: Option<bool>, /// [`minPinLength`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-minpinlength-extension). - pub min_pin_length: Option<u8>, + pub min_pin_length: Option<FourToSixtyThree>, } impl AuthExtOutput for AuthenticatorExtensionOutput { fn missing(self) -> bool { @@ -128,7 +126,7 @@ pub struct AuthenticatorExtensionOutputStaticState { #[derive(Clone, Copy, Debug)] pub struct AuthenticatorExtensionOutputMetadata { /// [`AuthenticatorExtensionOutput::min_pin_length`]. - pub min_pin_length: Option<u8>, + pub min_pin_length: Option<FourToSixtyThree>, } impl From<AuthenticatorExtensionOutput> for AuthenticatorExtensionOutputMetadata { #[inline] @@ -244,8 +242,8 @@ impl FromCbor<'_> for HmacSecret { enum MinPinLength { /// No `minPinLength` extension. None, - /// `minPinLength` with the value of the contained `u8`. - Val(u8), + /// `minPinLength` with the value of the contained `FourToSixtyThree`. + Val(FourToSixtyThree), } impl FromCbor<'_> for MinPinLength { type Err = AuthenticatorExtensionOutputErr; @@ -277,19 +275,25 @@ impl FromCbor<'_> for MinPinLength { .split_first() .ok_or(AuthenticatorExtensionOutputErr::Len) .and_then(|(&key_len, remaining)| match key_len.cmp(&24) { - Ordering::Less => Ok(CborSuccess { - value: Self::Val(key_len), - remaining, - }), + Ordering::Less => FourToSixtyThree::new(key_len) + .ok_or(AuthenticatorExtensionOutputErr::MinPinLengthValue) + .map(|val| CborSuccess { + value: Self::Val(val), + remaining, + }), Ordering::Equal => remaining .split_first() .ok_or(AuthenticatorExtensionOutputErr::Len) .and_then(|(&key_24, rem)| { if key_24 > 23 { - Ok(CborSuccess { - value: Self::Val(key_24), - remaining: rem, - }) + FourToSixtyThree::new(key_24) + .ok_or( + AuthenticatorExtensionOutputErr::MinPinLengthValue, + ) + .map(|val| CborSuccess { + value: Self::Val(val), + remaining: rem, + }) } else { Err(AuthenticatorExtensionOutputErr::MinPinLengthValue) } @@ -512,6 +516,12 @@ impl Ed25519PubKey<&[u8]> { ) .map_err(|_e| PubKeyErr::Ed25519) } + /// Transforms `self` into an "owned" version. + #[inline] + #[must_use] + pub fn into_owned(self) -> Ed25519PubKey<[u8; ed25519_dalek::PUBLIC_KEY_LENGTH]> { + Ed25519PubKey(*Ed25519PubKey::<&[u8; ed25519_dalek::PUBLIC_KEY_LENGTH]>::from(self).0) + } } impl Ed25519PubKey<[u8; ed25519_dalek::PUBLIC_KEY_LENGTH]> { /// Validates `self` is in fact a valid Ed25519 public key. @@ -647,6 +657,18 @@ impl<'a> UncompressedP256PubKey<'a> { // `self.1.len() == 32`, so this won't `panic`. self.1[31] & 1 == 1 } + /// Transforms `self` into the compressed version that owns the data. + #[expect(clippy::unreachable, reason = "want to crash when there is a bug")] + #[inline] + #[must_use] + pub fn into_compressed( + self, + ) -> CompressedP256PubKey<[u8; <NistP256 as Curve>::FieldBytesSize::INT]> { + CompressedP256PubKey { + x: self.0.try_into().unwrap_or_else(|_e| unreachable!("there is a bug in UncompressedP256PubKey that allows for the x-coordinate to not be 32 bytes in length")), + y_is_odd: self.y_is_odd(), + } + } } impl<'a: 'b, 'b> TryFrom<(&'a [u8], &'a [u8])> for UncompressedP256PubKey<'b> { type Error = UncompressedP256PubKeyErr; @@ -894,6 +916,18 @@ impl<'a> UncompressedP384PubKey<'a> { // `self.1.len() == 48`, so this won't `panic`. self.1[47] & 1 == 1 } + /// Transforms `self` into the compressed version that owns the data. + #[expect(clippy::unreachable, reason = "want to crash when there is a bug")] + #[inline] + #[must_use] + pub fn into_compressed( + self, + ) -> CompressedP384PubKey<[u8; <NistP384 as Curve>::FieldBytesSize::INT]> { + CompressedP384PubKey { + x: self.0.try_into().unwrap_or_else(|_e| unreachable!("there is a bug in UncompressedP384PubKey that allows for the x-coordinate to not be 48 bytes in length")), + y_is_odd: self.y_is_odd(), + } + } } impl<'a: 'b, 'b> TryFrom<(&'a [u8], &'a [u8])> for UncompressedP384PubKey<'b> { type Error = UncompressedP384PubKeyErr; @@ -1165,6 +1199,14 @@ impl<T: AsRef<[u8]>> RsaPubKey<T> { .map(RsaVerKey::new) } } +impl RsaPubKey<&[u8]> { + /// Transforms `self` into an "owned" version. + #[inline] + #[must_use] + pub fn into_owned(self) -> RsaPubKey<Vec<u8>> { + RsaPubKey(self.0.to_owned(), self.1) + } +} impl<'a: 'b, 'b> TryFrom<(&'a [u8], u32)> for RsaPubKey<&'b [u8]> { type Error = RsaPubKeyErr; /// The first item is the big-endian modulus, and the second item is the exponent. @@ -1636,6 +1678,24 @@ impl UncompressedPubKey<'_> { Self::Rsa(k) => k.validate(), } } + /// Transforms `self` into the compressed version that owns the data. + #[inline] + #[must_use] + pub fn into_compressed( + self, + ) -> CompressedPubKey< + [u8; ed25519_dalek::PUBLIC_KEY_LENGTH], + [u8; <NistP256 as Curve>::FieldBytesSize::INT], + [u8; <NistP384 as Curve>::FieldBytesSize::INT], + Vec<u8>, + > { + match self { + Self::Ed25519(key) => CompressedPubKey::Ed25519(key.into_owned()), + Self::P256(key) => CompressedPubKey::P256(key.into_compressed()), + Self::P384(key) => CompressedPubKey::P384(key.into_compressed()), + Self::Rsa(key) => CompressedPubKey::Rsa(key.into_owned()), + } + } } impl PartialEq<UncompressedPubKey<'_>> for UncompressedPubKey<'_> { #[inline] @@ -1839,7 +1899,6 @@ pub struct AttestedCredentialData<'a> { /// [`credentialPublicKey`](https://www.w3.org/TR/webauthn-3/#authdata-attestedcredentialdata-credentialpublickey). pub credential_public_key: UncompressedPubKey<'a>, } - impl<'a> FromCbor<'a> for AttestedCredentialData<'a> { type Err = AttestedCredentialDataErr; #[expect(clippy::big_endian_bytes, reason = "CBOR integers are big-endian")] @@ -1925,6 +1984,27 @@ impl<'a> AuthenticatorData<'a> { self.extensions } } +impl<'a: 'b, 'b> TryFrom<&'a [u8]> for AuthenticatorData<'b> { + type Error = AuthenticatorDataErr; + /// Deserializes `value` based on the + /// [authenticator data structure](https://www.w3.org/TR/webauthn-3/#table-authData). + #[expect( + clippy::panic_in_result_fn, + reason = "we want to crash when there is a bug" + )] + #[inline] + fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> { + Self::from_cbor(value) + .map_err(AuthenticatorDataErr::from) + .map(|success| { + assert!( + success.remaining.is_empty(), + "there is a bug in AuthenticatorData::from_cbor" + ); + success.value + }) + } +} impl<'a> AuthData<'a> for AuthenticatorData<'a> { type UpBitErr = Infallible; type CredData = AttestedCredentialData<'a>; @@ -1957,27 +2037,6 @@ impl<'a> AuthData<'a> for AuthenticatorData<'a> { self.flags } } -impl<'a: 'b, 'b> TryFrom<&'a [u8]> for AuthenticatorData<'b> { - type Error = AuthenticatorDataErr; - /// Deserializes `value` based on the - /// [authenticator data structure](https://www.w3.org/TR/webauthn-3/#table-authData). - #[expect( - clippy::panic_in_result_fn, - reason = "we want to crash when there is a bug" - )] - #[inline] - fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> { - Self::from_cbor(value) - .map_err(AuthenticatorDataErr::from) - .map(|success| { - assert!( - success.remaining.is_empty(), - "there is a bug in AuthenticatorData::from_cbor" - ); - success.value - }) - } -} /// [None](https://www.w3.org/TR/webauthn-3/#sctn-none-attestation). struct NoneAttestation; impl FromCbor<'_> for NoneAttestation { @@ -2226,7 +2285,7 @@ impl<'a> FromCbor<'a> for PackedAttestation<'a> { fn from_cbor(cbor: &'a [u8]) -> Result<CborSuccess<'a, Self>, Self::Err> { /// Parses `data` as packed attestation up until `x5c` _without_ the `cbor::MAP_2` or `cbor::MAP_3` header. fn parse_to_cert_chain(data: &[u8]) -> Result<CborSuccess<'_, Sig<'_>>, AttestationErr> { - // {"alg":CoseAlgId,"sig":sig_bytes...} + // {"alg":CoseAlgorithmIdentifier,"sig":sig_bytes...} /// "alg" key. const ALG: [u8; 4] = [cbor::TEXT_3, b'a', b'l', b'g']; /// "sig" key. @@ -2348,7 +2407,7 @@ impl<'a> FromCbor<'a> for AttestationFormat<'a> { type Err = AttestationErr; fn from_cbor(cbor: &'a [u8]) -> Result<CborSuccess<'a, Self>, Self::Err> { // Note we assume that cbor starts _after_ `cbor::MAP_3`. - // {"fmt":"none"|"packed", "attStmt": NoneAttestation|PackedAttestation, ...}. + // {"fmt":"none"|"packed", "attStmt": NoneAttestation|CborPacked, ...}. /// "fmt" key. const FMT: [u8; 4] = [cbor::TEXT_3, b'f', b'm', b't']; /// "none" value. @@ -2415,34 +2474,11 @@ impl<'a> FromCbor<'a> for AttestationFormat<'a> { }) } } -/// [Attestation object](https://www.w3.org/TR/webauthn-3/#attestation-object). -#[derive(Debug)] -pub struct AttestationObject<'a> { - /// [Attestation statement format identifiers](https://www.w3.org/TR/webauthn-3/#sctn-attstn-fmt-ids). - attestation: AttestationFormat<'a>, - /// [Authenticator data](https://www.w3.org/TR/webauthn-3/#authenticator-data). - auth_data: AuthenticatorData<'a>, -} impl<'a> AttestationObject<'a> { - /// [Attestation statement format identifiers](https://www.w3.org/TR/webauthn-3/#sctn-attstn-fmt-ids). - #[inline] - #[must_use] - pub const fn attestation(&self) -> AttestationFormat<'a> { - self.attestation - } - /// [Authenticator data](https://www.w3.org/TR/webauthn-3/#authenticator-data). - #[inline] - #[must_use] - pub const fn auth_data(&self) -> &AuthenticatorData<'a> { - &self.auth_data - } /// Deserializes `data` based on the /// [attestation object layout](https://www.w3.org/TR/webauthn-3/#attestation-object) /// returning [`Self`] and the index within `data` that the authenticator data portion /// begins. - /// - /// This mainly exists to unify [`Self::from_data`], [`Self::try_from`], and - /// [`ser::AuthenticatorAttestationVisitor::visit_map`]. #[expect( clippy::panic_in_result_fn, reason = "we want to crash when there is a bug" @@ -2456,7 +2492,7 @@ impl<'a> AttestationObject<'a> { /// `authData` key. const AUTH_DATA_KEY: [u8; 9] = [cbor::TEXT_8, b'a', b'u', b't', b'h', b'D', b'a', b't', b'a']; - // {"fmt":<AttestationFormat>, "attStmt":<AttestationStatement>, "authData":<AuthenticatorData>}. + // {"fmt":<AttestationFormat>, "attStmt":<AttestationObject>, "authData":<AuthentcatorData>}. data.split_first().ok_or(AttestationObjectErr::Len).and_then(|(map, map_rem)| { if *map == cbor::MAP_3 { AttestationFormat::from_cbor(map_rem).map_err(AttestationObjectErr::Attestation).and_then(|att| { @@ -2534,6 +2570,37 @@ impl<'a> AttestationObject<'a> { }) } } +/// [Attestation object](https://www.w3.org/TR/webauthn-3/#attestation-object). +#[derive(Debug)] +pub struct AttestationObject<'a> { + /// [Attestation statement format identifiers](https://www.w3.org/TR/webauthn-3/#sctn-attstn-fmt-ids). + attestation: AttestationFormat<'a>, + /// [Authenticator data](https://www.w3.org/TR/webauthn-3/#authenticator-data). + auth_data: AuthenticatorData<'a>, +} +impl<'a> AttestationObject<'a> { + /// [Attestation statement format identifiers](https://www.w3.org/TR/webauthn-3/#sctn-attstn-fmt-ids). + #[inline] + #[must_use] + pub const fn attestation(&self) -> AttestationFormat<'a> { + self.attestation + } + /// [Authenticator data](https://www.w3.org/TR/webauthn-3/#authenticator-data). + #[inline] + #[must_use] + pub const fn auth_data(&self) -> &AuthenticatorData<'a> { + &self.auth_data + } +} +impl<'a: 'b, 'b> TryFrom<&'a [u8]> for AttestationObject<'b> { + type Error = AttestationObjectErr; + /// Deserializes `value` based on the + /// [attestation object layout](https://www.w3.org/TR/webauthn-3/#attestation-object). + #[inline] + fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> { + Self::parse_data(value).map(|(val, _)| val) + } +} impl<'a> AuthDataContainer<'a> for AttestationObject<'a> { type Auth = AuthenticatorData<'a>; type Err = AttestationObjectErr; @@ -2548,15 +2615,6 @@ impl<'a> AuthDataContainer<'a> for AttestationObject<'a> { &self.auth_data } } -impl<'a: 'b, 'b> TryFrom<&'a [u8]> for AttestationObject<'b> { - type Error = AttestationObjectErr; - /// Deserializes `value` based on the - /// [attestation object layout](https://www.w3.org/TR/webauthn-3/#attestation-object). - #[inline] - fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> { - Self::parse_data(value).map(|(val, _)| val) - } -} /// [`AuthenticatorAttestationResponse`](https://www.w3.org/TR/webauthn-3/#authenticatorattestationresponse). #[derive(Debug)] pub struct AuthenticatorAttestation { @@ -2572,7 +2630,7 @@ impl AuthenticatorAttestation { /// [`clientDataJSON`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorresponse-clientdatajson). #[inline] #[must_use] - pub fn client_data_json(&self) -> &[u8] { + pub const fn client_data_json(&self) -> &[u8] { self.client_data_json.as_slice() } /// [attestation object](https://www.w3.org/TR/webauthn-3/#attestation-object). @@ -2716,6 +2774,34 @@ pub struct ClientExtensionsOutputs { /// [`prf`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsclientoutputs-prf). pub prf: Option<AuthenticationExtensionsPrfOutputs>, } +/// [`ClientExtensionsOutputs`] extensions that are saved in [`Metadata`] because they are purely informative +/// and not used during authentication ceremonies. +#[derive(Clone, Copy, Debug)] +pub struct ClientExtensionsOutputsMetadata { + /// [`ClientExtensionsOutputs::cred_props`]. + pub cred_props: Option<CredentialPropertiesOutput>, +} +/// [`ClientExtensionsOutputs`] extensions that are saved in [`StaticState`] because they are used during +/// authentication ceremonies. +#[derive(Clone, Copy, Debug)] +pub struct ClientExtensionsOutputsStaticState { + /// [`ClientExtensionsOutputs::prf`]. + pub prf: Option<AuthenticationExtensionsPrfOutputs>, +} +impl From<ClientExtensionsOutputs> for ClientExtensionsOutputsMetadata { + #[inline] + fn from(value: ClientExtensionsOutputs) -> Self { + Self { + cred_props: value.cred_props, + } + } +} +impl From<ClientExtensionsOutputs> for ClientExtensionsOutputsStaticState { + #[inline] + fn from(value: ClientExtensionsOutputs) -> Self { + Self { prf: value.prf } + } +} /// [`PublicKeyCredential`](https://www.w3.org/TR/webauthn-3/#iface-pkcredential) for registration ceremonies. #[expect( clippy::field_scoped_visibility_modifiers, @@ -2859,7 +2945,7 @@ pub struct Metadata<'a> { pub extensions: AuthenticatorExtensionOutputMetadata, /// [`getClientExtensionResults`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-getclientextensionresults) /// output during registration that is never used during authentication ceremonies. - pub client_extension_results: ClientExtensionsOutputs, + pub client_extension_results: ClientExtensionsOutputsMetadata, /// `ResidentKeyRequirement` sent during registration. pub resident_key: ResidentKeyRequirement, } @@ -2875,8 +2961,7 @@ impl Metadata<'_> { /// "min_pin_length": null | <8-bit unsigned integer without leading 0s> /// }, /// "client_extension_results": { - /// "cred_props": null | CredPropsJSON, - /// "prf": null | PrfJSON + /// "cred_props": null | CredPropsJSON /// }, /// "resident_key": "required" | "discouraged" | "preferred" /// } @@ -2901,10 +2986,10 @@ impl Metadata<'_> { /// ``` /// # use core::str::FromStr; /// # use webauthn_rp::{ - /// # request::register::ResidentKeyRequirement, + /// # request::register::{FourToSixtyThree, ResidentKeyRequirement}, /// # response::register::{ /// # Aaguid, Attestation, - /// # AuthenticatorExtensionOutputMetadata, ClientExtensionsOutputs, CredentialPropertiesOutput, + /// # AuthenticatorExtensionOutputMetadata, ClientExtensionsOutputsMetadata, CredentialPropertiesOutput, /// # Metadata, /// # }, /// # }; @@ -2912,13 +2997,12 @@ impl Metadata<'_> { /// attestation: Attestation::None, /// aaguid: Aaguid::try_from([15; 16].as_slice())?, /// extensions: AuthenticatorExtensionOutputMetadata { - /// min_pin_length: Some(16), + /// min_pin_length: Some(FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new"))), /// }, - /// client_extension_results: ClientExtensionsOutputs { + /// client_extension_results: ClientExtensionsOutputsMetadata { /// cred_props: Some(CredentialPropertiesOutput { /// rk: Some(true), /// }), - /// prf: None, /// }, /// resident_key: ResidentKeyRequirement::Required /// }; @@ -2931,8 +3015,7 @@ impl Metadata<'_> { /// "client_extension_results": { /// "cred_props": { /// "rk": true - /// }, - /// "prf": null + /// } /// }, /// "resident_key": "required" /// }); @@ -2946,19 +3029,15 @@ impl Metadata<'_> { clippy::integer_division_remainder_used, reason = "comments justify their correctness" )] - #[expect( - clippy::else_if_without_else, - reason = "don't want an empty else branch" - )] #[inline] #[must_use] pub fn into_json(self) -> String { - // Maximum capacity needed is not _that_ much larger than the minimum, 184. An example is the + // Maximum capacity needed is not _that_ much larger than the minimum, 173. An example is the // following: // `{"attestation":"none","aaguid":"00000000000000000000000000000000","extensions":{"min_pin_length":null},"client_extension_results":{"cred_props":{"rk":false},"prf":{"enabled":false}},"resident_key":"discouraged"}`. // We use a raw `Vec` instead of a `String` since we need to transform some binary values into ASCII which // is easier to do as bytes. - let mut buffer = Vec::with_capacity(211); + let mut buffer = Vec::with_capacity(187); buffer.extend_from_slice(br#"{"attestation":"#); buffer.extend_from_slice(match self.attestation { Attestation::None => br#""none","aaguid":""#, @@ -2983,26 +3062,17 @@ impl Metadata<'_> { None => buffer.extend_from_slice(b"null"), Some(pin) => { // Clearly correct. - let dig_1 = pin / 100; + let dig_1 = pin.value() / 10; // Clearly correct. - let dig_2 = (pin % 100) / 10; - // Clearly correct. - let dig_3 = pin % 10; + let dig_2 = pin.value() % 10; if dig_1 > 0 { // We simply add the appropriate offset which is `b'0` for decimal digits. // Overflow cannot occur since this maxes at `b'9'`. buffer.push(dig_1 + b'0'); - // We simply add the appropriate offset which is `b'0` for decimal digits. - // Overflow cannot occur since this maxes at `b'9'`. - buffer.push(dig_2 + b'0'); - } else if dig_2 > 0 { - // We simply add the appropriate offset which is `b'0` for decimal digits. - // Overflow cannot occur since this maxes at `b'9'`. - buffer.push(dig_2 + b'0'); } // We simply add the appropriate offset which is `b'0` for decimal digits. // Overflow cannot occur since this maxes at `b'9'`. - buffer.push(dig_3 + b'0'); + buffer.push(dig_2 + b'0'); } } buffer.extend_from_slice(br#"},"client_extension_results":{"cred_props":"#); @@ -3016,17 +3086,6 @@ impl Metadata<'_> { } } } - buffer.extend_from_slice(br#","prf":"#); - match self.client_extension_results.prf { - None => buffer.extend_from_slice(b"null"), - Some(prf) => { - buffer.extend_from_slice(if prf.enabled { - br#"{"enabled":true}"# - } else { - br#"{"enabled":false}"# - }); - } - } buffer.extend_from_slice(br#"},"resident_key":"#); buffer.extend_from_slice(match self.resident_key { ResidentKeyRequirement::Required => br#""required"}"#, @@ -3038,6 +3097,25 @@ impl Metadata<'_> { // is valid UTF-8. unsafe { String::from_utf8_unchecked(buffer) } } + /// Transforms `self` into an "owned" version. + #[expect(clippy::unreachable, reason = "we want to crash when there is a bug")] + #[cfg_attr(docsrs, doc(cfg(feature = "bin")))] + #[cfg(feature = "bin")] + #[inline] + #[must_use] + pub fn into_owned(self) -> MetadataOwned { + MetadataOwned { + attestation: self.attestation, + aaguid: AaguidOwned(self.aaguid.0.try_into().unwrap_or_else(|_e| { + unreachable!( + "there is a bug in Metadata that allows AAGUID to not have length of 16" + ) + })), + extensions: self.extensions, + client_extension_results: self.client_extension_results, + resident_key: self.resident_key, + } + } } /// [`RegisteredCredential`] and [`AuthenticatedCredential`] static state. /// @@ -3049,6 +3127,45 @@ pub struct StaticState<PublicKey> { /// [`extensions`](https://www.w3.org/TR/webauthn-3/#authdata-extensions) output during registration that are /// used during authentication ceremonies. pub extensions: AuthenticatorExtensionOutputStaticState, + /// [`getClientExtensionResults`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredential-getclientextensionresults) + /// output during registration that are used during authentication ceremonies. + pub client_extension_results: ClientExtensionsOutputsStaticState, +} +impl<'a: 'b, 'b, T: AsRef<[u8]>, T2: AsRef<[u8]>, T3: AsRef<[u8]>, T4: AsRef<[u8]>> + From<&'a StaticState<CompressedPubKey<T, T2, T3, T4>>> + for StaticState<CompressedPubKey<&'b [u8], &'b [u8], &'b [u8], &'b [u8]>> +{ + #[inline] + fn from(value: &'a StaticState<CompressedPubKey<T, T2, T3, T4>>) -> Self { + Self { + credential_public_key: (&value.credential_public_key).into(), + extensions: value.extensions, + client_extension_results: value.client_extension_results, + } + } +} +/// `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>, + >, +>; +impl StaticStateUncompressed<'_> { + /// Transforms `self` into `StaticState` that contains the compressed version of the public key. + #[inline] + #[must_use] + pub fn into_compressed(self) -> StaticStateCompressed { + StaticStateCompressed { + credential_public_key: self.credential_public_key.into_compressed(), + extensions: self.extensions, + client_extension_results: self.client_extension_results, + } + } } /// [`RegisteredCredential`] and [`AuthenticatedCredential`] dynamic state. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -3056,7 +3173,11 @@ pub struct DynamicState { /// [`UV`](https://www.w3.org/TR/webauthn-3/#authdata-flags-uv). /// /// Once this is `true`, it will remain `true`. It will be set to `true` when `false` iff - /// [`Flag::user_verified`] and [`AuthenticationVerificationOptions::update_uv`]. + /// [`Flag::user_verified`] and [`AuthenticationVerificationOptions::update_uv`]. In other words, this only + /// means that the user has been verified _at some point in the past_; but it does _not_ mean the user has + /// most-recently been verified this is because user verification is a _ceremony-specific_ property. To + /// enforce user verification for all ceremonies, [`UserVerificationRequirement::Required`] must always be + /// sent. pub user_verified: bool, /// This can only be updated if [`BackupReq`] allows for it. pub backup: Backup, diff --git a/src/response/register/bin.rs b/src/response/register/bin.rs @@ -1,17 +1,13 @@ -#![expect( - clippy::unseparated_literal_suffix, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] use super::{ super::super::bin::{ Decode, DecodeBuffer, EncDecErr, Encode, EncodeBuffer, EncodeBufferFallible as _, }, Aaguid, Attestation, AuthenticationExtensionsPrfOutputs, AuthenticatorAttachment, AuthenticatorExtensionOutputMetadata, AuthenticatorExtensionOutputStaticState, Backup, - ClientExtensionsOutputs, CompressedP256PubKey, CompressedP384PubKey, CompressedPubKey, - CredentialPropertiesOutput, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, Metadata, - ResidentKeyRequirement, RsaPubKey, StaticState, UncompressedP256PubKey, UncompressedP384PubKey, - UncompressedPubKey, + ClientExtensionsOutputsMetadata, ClientExtensionsOutputsStaticState, CompressedP256PubKey, + CompressedP384PubKey, CompressedPubKey, CredentialPropertiesOutput, CredentialProtectionPolicy, + DynamicState, Ed25519PubKey, FourToSixtyThree, Metadata, ResidentKeyRequirement, RsaPubKey, + StaticState, UncompressedP256PubKey, UncompressedP384PubKey, UncompressedPubKey, }; use core::{ convert::Infallible, @@ -19,8 +15,8 @@ use core::{ fmt::{self, Display, Formatter}, }; use p256::{ - elliptic_curve::{generic_array::typenum::ToInt as _, Curve}, NistP256, + elliptic_curve::{Curve, generic_array::typenum::ToInt as _}, }; use p384::NistP384; impl EncodeBuffer for CredentialProtectionPolicy { @@ -289,11 +285,22 @@ impl<'a> DecodeBuffer<'a> for AaguidOwned { <[u8; super::AAGUID_LEN]>::decode_from_buffer(data).map(Self) } } +impl EncodeBuffer for FourToSixtyThree { + fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { + self.value().encode_into_buffer(buffer); + } +} impl EncodeBuffer for AuthenticatorExtensionOutputMetadata { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { self.min_pin_length.encode_into_buffer(buffer); } } +impl<'a> DecodeBuffer<'a> for FourToSixtyThree { + type Err = EncDecErr; + fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { + u8::decode_from_buffer(data).and_then(|val| Self::new(val).ok_or(EncDecErr)) + } +} impl<'a> DecodeBuffer<'a> for AuthenticatorExtensionOutputMetadata { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { @@ -322,18 +329,26 @@ impl<'a> DecodeBuffer<'a> for AuthenticationExtensionsPrfOutputs { bool::decode_from_buffer(data).map(|enabled| Self { enabled }) } } -impl EncodeBuffer for ClientExtensionsOutputs { +impl EncodeBuffer for ClientExtensionsOutputsMetadata { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { self.cred_props.encode_into_buffer(buffer); + } +} +impl EncodeBuffer for ClientExtensionsOutputsStaticState { + fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { self.prf.encode_into_buffer(buffer); } } -impl<'a> DecodeBuffer<'a> for ClientExtensionsOutputs { +impl<'a> DecodeBuffer<'a> for ClientExtensionsOutputsMetadata { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { - Option::decode_from_buffer(data).and_then(|cred_props| { - Option::decode_from_buffer(data).map(|prf| Self { cred_props, prf }) - }) + Option::decode_from_buffer(data).map(|cred_props| Self { cred_props }) + } +} +impl<'a> DecodeBuffer<'a> for ClientExtensionsOutputsStaticState { + type Err = EncDecErr; + fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { + Option::decode_from_buffer(data).map(|prf| Self { prf }) } } impl Encode for Metadata<'_> { @@ -348,9 +363,9 @@ impl Encode for Metadata<'_> { // * 1 for `Attestation` // * 16 for `Aaguid`. // * 1 or 2 for `AuthenticatorExtensionOutputMetadata` where we assume 1 is the most common - // * 2–5 for `ClientExtensionsOutputs` where we assume 2 is the most common + // * 1–3 for `ClientExtensionsOutputsMetadata` where we assume 1 is the most common // * 1 for `ResidentKeyRequirement` - let mut buffer = Vec::with_capacity(1 + 16 + 1 + 2 + 1); + let mut buffer = Vec::with_capacity(1 + 16 + 1 + 1 + 1); self.attestation.encode_into_buffer(&mut buffer); self.aaguid.encode_into_buffer(&mut buffer); self.extensions.encode_into_buffer(&mut buffer); @@ -370,7 +385,7 @@ pub struct MetadataOwned { /// [`Metadata::extensions`]. pub extensions: AuthenticatorExtensionOutputMetadata, /// [`Metadata::client_extension_results`]. - pub client_extension_results: ClientExtensionsOutputs, + pub client_extension_results: ClientExtensionsOutputsMetadata, /// [`Metadata::resident_key`]. pub resident_key: ResidentKeyRequirement, } @@ -430,7 +445,7 @@ impl Decode for MetadataOwned { AuthenticatorExtensionOutputMetadata::decode_from_buffer(&mut input) .map_err(|_e| DecodeMetadataOwnedErr::Extensions) .and_then(|extensions| { - ClientExtensionsOutputs::decode_from_buffer(&mut input) + ClientExtensionsOutputsMetadata::decode_from_buffer(&mut input) .map_err(|_e| DecodeMetadataOwnedErr::ClientExtensionResults) .and_then(|client_extension_results| { ResidentKeyRequirement::decode_from_buffer(&mut input) @@ -469,7 +484,7 @@ impl Encode for StaticState<UncompressedPubKey<'_>> { #[inline] fn encode(&self) -> Result<Self::Output<'_>, Self::Err> { let mut buffer = Vec::with_capacity( - // The maximum value is 1 + 2 + 2048 + 4 + 1 + 1 + 1 = 2058 so overflow cannot happen. + // The maximum value is 1 + 2 + 2048 + 4 + 1 + 1 + 1 + 1 + 1 = 2060 so overflow cannot happen. // `key.0.len() <= MAX_RSA_N_BYTES` which is 2048. match self.credential_public_key { UncompressedPubKey::Ed25519(_) => 33, @@ -478,10 +493,14 @@ impl Encode for StaticState<UncompressedPubKey<'_>> { UncompressedPubKey::Rsa(key) => 1 + 2 + key.0.len() + 4, } + 1 + 1 - + usize::from(self.extensions.hmac_secret.is_some()), + + usize::from(self.extensions.hmac_secret.is_some()) + + 1 + + usize::from(self.client_extension_results.prf.is_some()), ); self.credential_public_key.encode_into_buffer(&mut buffer); self.extensions.encode_into_buffer(&mut buffer); + self.client_extension_results + .encode_into_buffer(&mut buffer); Ok(buffer) } } @@ -492,6 +511,8 @@ pub enum DecodeStaticStateErr { CredentialPublicKey, /// Variant returned when [`StaticState::extensions`] could not be decoded. Extensions, + /// Variant returned when [`StaticState::client_extension_results`] could not be decoded. + ClientExtensionResults, /// Variant returned when there was trailing data after decoding a [`StaticState`]. TrailingData, } @@ -501,6 +522,7 @@ impl Display for DecodeStaticStateErr { f.write_str(match *self { Self::CredentialPublicKey => "credential_public_key could not be decoded", Self::Extensions => "extensions could not be decoded", + Self::ClientExtensionResults => "client_extension_results could not be decoded", Self::TrailingData => "there was trailing data after decoding a StaticState", }) } @@ -527,14 +549,19 @@ impl Decode AuthenticatorExtensionOutputStaticState::decode_from_buffer(&mut input) .map_err(|_e| DecodeStaticStateErr::Extensions) .and_then(|extensions| { - if input.is_empty() { - Ok(Self { - credential_public_key, - extensions, + ClientExtensionsOutputsStaticState::decode_from_buffer(&mut input) + .map_err(|_e| DecodeStaticStateErr::ClientExtensionResults) + .and_then(|client_extension_results| { + if input.is_empty() { + Ok(Self { + credential_public_key, + extensions, + client_extension_results, + }) + } else { + Err(DecodeStaticStateErr::TrailingData) + } }) - } else { - Err(DecodeStaticStateErr::TrailingData) - } }) }) } diff --git a/src/response/register/error.rs b/src/response/register/error.rs @@ -3,30 +3,30 @@ use super::super::SerdeJsonErr; #[cfg(doc)] use super::{ super::super::{ + RegisteredCredential, request::{ + BackupReq, CredentialMediationRequirement, UserVerificationRequirement, register::{ AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, Extension, PublicKeyCredentialCreationOptions, RegistrationServerState, RegistrationVerificationOptions, }, - BackupReq, CredentialMediationRequirement, UserVerificationRequirement, }, - RegisteredCredential, }, Aaguid, Attestation, AttestationObject, AttestedCredentialData, AuthenticatorAttachment, AuthenticatorAttestation, AuthenticatorData, AuthenticatorExtensionOutput, Backup, ClientExtensionsOutputs, CollectedClientData, CompressedP256PubKey, CompressedP384PubKey, - Ed25519PubKey, Ed25519Signature, Flag, Metadata, PackedAttestation, RsaPubKey, - UncompressedP256PubKey, UncompressedP384PubKey, UncompressedPubKey, MAX_RSA_N_BYTES, MIN_RSA_E, - MIN_RSA_N_BYTES, + Ed25519PubKey, Ed25519Signature, Flag, MAX_RSA_N_BYTES, MIN_RSA_E, MIN_RSA_N_BYTES, Metadata, + PackedAttestation, RsaPubKey, UncompressedP256PubKey, UncompressedP384PubKey, + UncompressedPubKey, }; use super::{ super::{ - super::{request::register::CredProtect, CredentialErr}, - error::{CollectedClientDataErr, CredentialIdErr}, + super::{CredentialErr, request::register::CredProtect}, AuthRespErr, AuthenticatorDataErr as AuthDataErr, CeremonyErr, + error::{CollectedClientDataErr, CredentialIdErr}, }, - CredentialProtectionPolicy, + CredentialProtectionPolicy, FourToSixtyThree, }; use core::{ convert::Infallible, @@ -476,8 +476,6 @@ pub enum ExtensionErr { MissingPrf, /// [`Extension::cred_protect`] was requested, but the required response was not sent back. MissingCredProtect, - /// [`Extension::prf`] was requested, but the required response was not sent back. - MissingHmacSecret, /// [`Extension::min_pin_length`] was requested, but the required response was not sent back. MissingMinPinLength, /// [`Extension::cred_protect`] was requested with the first policy, but the second policy was sent back. @@ -492,7 +490,7 @@ pub enum ExtensionErr { /// [`Extension::min_pin_length`] was requested, but /// [`minPinLength`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-minpinlength-extension) /// was sent set to the second value which is strictly less than the required first value. - InvalidMinPinLength(u8, u8), + InvalidMinPinLength(FourToSixtyThree, FourToSixtyThree), } impl Display for ExtensionErr { #[inline] @@ -516,7 +514,6 @@ impl Display for ExtensionErr { Self::MissingCredProps => f.write_str("credProps was not sent from the client"), Self::MissingPrf => f.write_str("prf was not sent from the client"), Self::MissingCredProtect => f.write_str("credProtect was not sent from the client"), - Self::MissingHmacSecret => f.write_str("hmac-secret was not sent from the client"), Self::MissingMinPinLength => f.write_str("minPinLength was not sent from the client"), Self::InvalidCredProtectValue(sent, rec) => write!( f, diff --git a/src/response/register/ser.rs b/src/response/register/ser.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::question_mark_used, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] use super::{ super::{ super::request::register::CoseAlgorithmIdentifier, @@ -926,22 +922,24 @@ impl<'d, const R: bool> Visitor<'d> for AuthenticatorAttestationVisitor<R> { // used. attested_info.as_ref().map_or_else( || AttestationObject::parse_data(attestation_object.as_slice()).map_err(Error::custom).and_then(|(att_obj, auth_idx)| { - if matches!(att_obj.auth_data.attested_credential_data.credential_public_key, UncompressedPubKey::P384(_)) { - // This won't overflow since `AttestationObject::parse_data` succeeded and `auth_idx` - // is the start of the raw authenticator data which itself contains the raw Credential ID. - Ok(Some((CoseAlgorithmIdentifier::Es384, auth_idx, auth_idx + att_obj.auth_data.attested_credential_data.credential_id.0.len()))) - } else { - Err(Error::missing_field(PUBLIC_KEY)) + match att_obj.auth_data.attested_credential_data.credential_public_key { + UncompressedPubKey::P384(_) => { + // This won't overflow since `AttestationObject::parse_data` succeeded and `auth_idx` + // is the start of the raw authenticator data which itself contains the raw Credential ID. + Ok(Some((CoseAlgorithmIdentifier::Es384, auth_idx, auth_idx + att_obj.auth_data.attested_credential_data.credential_id.0.len()))) + } + UncompressedPubKey::Ed25519(_) | UncompressedPubKey::P256(_) | UncompressedPubKey::Rsa(_) => Err(Error::missing_field(PUBLIC_KEY)), } }), - |&(ref attested_data, cred_id_start)| if matches!(attested_data.credential_public_key, UncompressedPubKey::P384(_)) { - // Overflow won't occur since this is correct. This is correct since we successfully parsed - // `AttestedCredentialData` and calculated `cred_id_start` from it. - Ok(Some((CoseAlgorithmIdentifier::Es384, cred_id_start, cred_id_start + attested_data.credential_id.0.len()))) - } else if flag { - Err(Error::invalid_type(Unexpected::Other("null"), &format!("{PUBLIC_KEY} to be a base64url-encoded DER-encoded SubjectPublicKeyInfo").as_str())) - } else { - Err(Error::missing_field(PUBLIC_KEY)) + |&(ref attested_data, cred_id_start)| { + match attested_data.credential_public_key { + UncompressedPubKey::P384(_) => { + // Overflow won't occur since this is correct. This is correct since we successfully parsed + // `AttestedCredentialData` and calculated `cred_id_start` from it. + Ok(Some((CoseAlgorithmIdentifier::Es384, cred_id_start, cred_id_start + attested_data.credential_id.0.len()))) + } + UncompressedPubKey::Ed25519(_) | UncompressedPubKey::P256(_) | UncompressedPubKey::Rsa(_) => if flag { Err(Error::invalid_type(Unexpected::Other("null"), &format!("{PUBLIC_KEY} to be a base64url-encoded DER-encoded SubjectPublicKeyInfo").as_str())) } else { Err(Error::missing_field(PUBLIC_KEY)) }, + } } ) } @@ -951,28 +949,35 @@ impl<'d, const R: bool> Visitor<'d> for AuthenticatorAttestationVisitor<R> { attested_info.as_ref().map_or_else( || AttestationObject::parse_data(attestation_object.as_slice()).map_err(Error::custom).and_then(|(att_obj, auth_idx)| { if key == att_obj.auth_data.attested_credential_data.credential_public_key { + let alg = match att_obj.auth_data.attested_credential_data.credential_public_key { + UncompressedPubKey::Ed25519(_) => CoseAlgorithmIdentifier::Eddsa, + UncompressedPubKey::P256(_) => CoseAlgorithmIdentifier::Es256, + UncompressedPubKey::P384(_) => CoseAlgorithmIdentifier::Es384, + UncompressedPubKey::Rsa(_) => CoseAlgorithmIdentifier::Rs256, + }; // This won't overflow since `AttestationObject::parse_data` succeeded and `auth_idx` // is the start of the raw authenticator data which itself contains the raw Credential ID. - Ok((auth_idx, auth_idx+ att_obj.auth_data.attested_credential_data.credential_id.0.len())) + Ok(Some((alg, auth_idx, auth_idx+ att_obj.auth_data.attested_credential_data.credential_id.0.len()))) } else { Err(Error::invalid_value(Unexpected::Bytes(der.0.as_slice()), &format!("DER-encoded public key to match the public key within the attestation object: {:?}", att_obj.auth_data.attested_credential_data.credential_public_key).as_str())) } }), |&(ref attested_data, cred_id_start)| { if key == attested_data.credential_public_key { + let alg = match attested_data.credential_public_key { + UncompressedPubKey::Ed25519(_) => CoseAlgorithmIdentifier::Eddsa, + UncompressedPubKey::P256(_) => CoseAlgorithmIdentifier::Es256, + UncompressedPubKey::P384(_) => CoseAlgorithmIdentifier::Es384, + UncompressedPubKey::Rsa(_) => CoseAlgorithmIdentifier::Rs256, + }; // Overflow won't occur since this is correct. This is correct since we successfully parsed // `AttestedCredentialData` and calculated `cred_id_start` from it. - Ok((cred_id_start, cred_id_start + attested_data.credential_id.0.len())) + Ok(Some((alg, cred_id_start, cred_id_start + attested_data.credential_id.0.len()))) } else { Err(Error::invalid_value(Unexpected::Bytes(der.0.as_slice()), &format!("DER-encoded public key to match the public key within the attestation object: {:?}", attested_data.credential_public_key).as_str())) } } - ).map(|(start, last)| Some((match key { - UncompressedPubKey::Ed25519(_) => CoseAlgorithmIdentifier::Eddsa, - UncompressedPubKey::P256(_) => CoseAlgorithmIdentifier::Es256, - UncompressedPubKey::P384(_) => CoseAlgorithmIdentifier::Es384, - UncompressedPubKey::Rsa(_) => CoseAlgorithmIdentifier::Rs256, - }, start, last))) + ) }) } ).and_then(|cred_key_alg_cred_info| { @@ -1065,9 +1070,10 @@ impl<'de> Deserialize<'de> for AuthenticatorAttestation { /// is deserialized via [`AuthTransports::deserialize`]; the decoded `publicKey` is parsed according to the /// applicable DER-encoded ASN.1 `SubjectPublicKeyInfo` schema; /// [`publicKeyAlgorithm`](https://www.w3.org/TR/webauthn-3/#dom-authenticatorattestationresponsejson-publickeyalgorithm) - /// is deserialized via [`CoseAlgorithmIdentifier::deserialize`]; all `required` fields in the - /// `AuthenticatorAttestationResponseJSON` Web IDL `dictionary` exist (and must not be `null`); `publicKey` exists when - /// Ed25519, P-256 with SHA-256, or RSASSA-PKCS1-v1_5 with SHA-256 is used (and must not be `null`) + /// is deserialized according to + /// [`CoseAlgorithmIdentifier`](https://www.w3.org/TR/webauthn-3/#typedefdef-cosealgorithmidentifier); all `required` + /// fields in the `AuthenticatorAttestationResponseJSON` Web IDL `dictionary` exist (and must not be `null`); `publicKey` + /// exists when Ed25519, P-256 with SHA-256, or RSASSA-PKCS1-v1_5 with SHA-256 is used (and must not be `null`) /// [per WebAuthn](https://www.w3.org/TR/webauthn-3/#sctn-public-key-easy); the `publicKeyAlgorithm` aligns /// with /// [`credentialPublicKey`](https://www.w3.org/TR/webauthn-3/#authdata-attestedcredentialdata-credentialpublickey) @@ -1176,7 +1182,7 @@ impl<'de> Deserialize<'de> for CredentialPropertiesOutput { } impl<'de> Deserialize<'de> for AuthenticationExtensionsPrfOutputs { /// Deserializes a `struct` based on - /// [`AuthenticationExtensionsPRFOutputs`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfoutputs). + /// [`AuthenticationExtensionsPRFOutputsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfoutputsjson). /// /// Note unknown and duplicate keys are forbidden; /// [`enabled`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfoutputs-enabled) @@ -1561,7 +1567,7 @@ mod tests { b't', b'a', cbor::BYTES_INFO_24, - 113, + 115, // `rpIdHash`. 0, 0, @@ -2242,7 +2248,7 @@ mod tests { // Unknown `transports`. err = Error::invalid_value( Unexpected::Str("Usb"), - &"'ble', 'hybrid', 'internal', 'nfc', 'smart-card', or 'usb'", + &"'ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', or 'usb'", ) .to_string() .into_bytes(); @@ -4583,7 +4589,7 @@ mod tests { ); // `publicKeyAlgorithm` mismatch. let mut err = Error::invalid_value( - Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Eddsa).as_str()), + Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Es256).as_str()), &format!("public key algorithm to match the algorithm associated with the public key within the attestation object: {:?}", CoseAlgorithmIdentifier::Es384).as_str() ) .to_string().into_bytes(); @@ -4597,7 +4603,7 @@ mod tests { "authenticatorData": b64_adata, "transports": [], "publicKey": b64_key, - "publicKeyAlgorithm": -8, + "publicKeyAlgorithm": -7, "attestationObject": b64_aobj, }, "clientExtensionResults": {}, @@ -4737,7 +4743,7 @@ mod tests { ); // `publicKeyAlgorithm` mismatch when `publicKey` does not exist. err = Error::invalid_value( - Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Eddsa).as_str()), + Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Es256).as_str()), &format!("public key algorithm to match the algorithm associated with the public key within the attestation object: {:?}", CoseAlgorithmIdentifier::Es384).as_str() ) .to_string().into_bytes(); @@ -4750,7 +4756,7 @@ mod tests { "clientDataJSON": b64_cdata, "authenticatorData": b64_adata, "transports": [], - "publicKeyAlgorithm": -8, + "publicKeyAlgorithm": -7, "attestationObject": b64_aobj, }, "clientExtensionResults": {}, @@ -4788,7 +4794,7 @@ mod tests { ); // `publicKeyAlgorithm` mismatch when `publicKey` is null. err = Error::invalid_value( - Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Eddsa).as_str()), + Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Es256).as_str()), &format!("public key algorithm to match the algorithm associated with the public key within the attestation object: {:?}", CoseAlgorithmIdentifier::Es384).as_str() ) .to_string().into_bytes(); @@ -4802,7 +4808,7 @@ mod tests { "authenticatorData": b64_adata, "transports": [], "publicKey": null, - "publicKeyAlgorithm": -8, + "publicKeyAlgorithm": -7, "attestationObject": b64_aobj, }, "clientExtensionResults": {}, diff --git a/src/response/register/ser_relaxed.rs b/src/response/register/ser_relaxed.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::question_mark_used, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] #[cfg(doc)] use super::super::{super::request::register::CoseAlgorithmIdentifier, Challenge, CredentialId}; use super::{ @@ -1122,7 +1118,7 @@ mod tests { // Unknown `transports`. err = Error::invalid_value( Unexpected::Str("Usb"), - &"'ble', 'hybrid', 'internal', 'nfc', 'smart-card', or 'usb'", + &"'ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', or 'usb'", ) .to_string() .into_bytes(); @@ -1716,7 +1712,7 @@ mod tests { // Unknown `transports`. err = Error::invalid_value( Unexpected::Str("Usb"), - &"'ble', 'hybrid', 'internal', 'nfc', 'smart-card', or 'usb'", + &"'ble', 'cable', 'hybrid', 'internal', 'nfc', 'smart-card', or 'usb'", ) .to_string() .into_bytes(); @@ -3827,7 +3823,7 @@ mod tests { ); // `publicKeyAlgorithm` mismatch. let mut err = Error::invalid_value( - Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Eddsa).as_str()), + Unexpected::Other(&format!("{:?}", CoseAlgorithmIdentifier::Es256).as_str()), &format!("public key algorithm to match the algorithm associated with the public key within the attestation object: {:?}", CoseAlgorithmIdentifier::Es384).as_str() ) .to_string().into_bytes(); @@ -3841,7 +3837,7 @@ mod tests { "authenticatorData": b64_adata, "transports": [], "publicKey": b64_key, - "publicKeyAlgorithm": -8, + "publicKeyAlgorithm": -7, "attestationObject": b64_aobj, }, "clientExtensionResults": {}, @@ -3939,13 +3935,8 @@ mod tests { }, "clientExtensionResults": {}, "type": "public-key" - }) - .to_string() - .as_str() - ) - .unwrap_err().to_string().into_bytes()[..err.len()], - err - ); + }).to_string().as_str() + ).unwrap_err().to_string().into_bytes()[..err.len()], err); // Missing `publicKey`. assert!( serde_json::from_str::<RegistrationRelaxed>( @@ -4732,8 +4723,8 @@ mod tests { .to_string() .as_str() ) - .unwrap_err().to_string().into_bytes()[..err.len()], - err + .unwrap_err().to_string().into_bytes()[..err.len()], + err ); // Missing `publicKey`. assert!( diff --git a/src/response/ser.rs b/src/response/ser.rs @@ -1,7 +1,3 @@ -#![expect( - clippy::question_mark_used, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] extern crate alloc; use super::{ AllAcceptedCredentialsOptions, AuthTransports, AuthenticatorAttachment, AuthenticatorTransport, @@ -62,6 +58,8 @@ impl<'de> Deserialize<'de> for AuthenticatorTransport { /// Deserializes [`prim@str`] based on /// [`AuthenticatorTransport`](https://www.w3.org/TR/webauthn-3/#enumdef-authenticatortransport). /// + /// Note `"cable"` is also supported and will be interpreted as [`Self::Hybrid`]. + /// /// # Examples /// /// ``` @@ -90,9 +88,11 @@ impl<'de> Deserialize<'de> for AuthenticatorTransport { where E: Error, { + /// Legacy version of [`Self::Hybrid`]. + const CA_BLE: &str = "cable"; match v { BLE => Ok(AuthenticatorTransport::Ble), - HYBRID => Ok(AuthenticatorTransport::Hybrid), + CA_BLE | HYBRID => Ok(AuthenticatorTransport::Hybrid), INTERNAL => Ok(AuthenticatorTransport::Internal), NFC => Ok(AuthenticatorTransport::Nfc), SMART_CARD => Ok(AuthenticatorTransport::SmartCard), @@ -100,7 +100,7 @@ impl<'de> Deserialize<'de> for AuthenticatorTransport { _ => Err(E::invalid_value( Unexpected::Str(v), &format!( - "'{BLE}', '{HYBRID}', '{INTERNAL}', '{NFC}', '{SMART_CARD}', or '{USB}'" + "'{BLE}', '{CA_BLE}', '{HYBRID}', '{INTERNAL}', '{NFC}', '{SMART_CARD}', or '{USB}'" ) .as_str(), )), @@ -805,7 +805,11 @@ where ) } } -impl<const USER_LEN: usize> Serialize for AllAcceptedCredentialsOptions<'_, '_, USER_LEN> { +use super::UserHandle; +impl<const USER_LEN: usize> Serialize for AllAcceptedCredentialsOptions<'_, '_, USER_LEN> +where + UserHandle<USER_LEN>: Serialize, +{ /// Serializes `self` to conform with /// [`AllAcceptedCredentialsOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-allacceptedcredentialsoptions). /// @@ -865,7 +869,10 @@ impl<const USER_LEN: usize> Serialize for AllAcceptedCredentialsOptions<'_, '_, }) } } -impl<const LEN: usize> Serialize for CurrentUserDetailsOptions<'_, '_, '_, '_, LEN> { +impl<const LEN: usize> Serialize for CurrentUserDetailsOptions<'_, '_, '_, '_, LEN> +where + UserHandle<LEN>: Serialize, +{ /// Serializes `self` to conform with /// [`CurrentUserDetailsOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-currentuserdetailsoptions). /// diff --git a/src/response/ser_relaxed.rs b/src/response/ser_relaxed.rs @@ -1,18 +1,13 @@ -#![expect( - clippy::pub_use, - clippy::question_mark_used, - reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs" -)] extern crate alloc; +#[cfg(doc)] +use super::{Challenge, LimitedVerificationParser}; use super::{ + ClientDataJsonParser, CollectedClientData, Origin, SentChallenge, ser::{ AuthenticationExtensionsPrfValues, AuthenticationExtensionsPrfValuesVisitor, PRF_VALUES_FIELDS, }, - ClientDataJsonParser, CollectedClientData, Origin, SentChallenge, }; -#[cfg(doc)] -use super::{Challenge, LimitedVerificationParser}; use alloc::borrow::Cow; use core::{ fmt::{self, Formatter}, @@ -70,11 +65,7 @@ impl<const R: bool> ClientDataJsonParser for RelaxedClientDataJsonParser<R> { serde_json::from_slice::<CDataJsonHelper<'_, R>>(json.split_at_checked(BOM.len()).map_or( json, |(bom, rem)| { - if bom == BOM { - rem - } else { - json - } + if bom == BOM { rem } else { json } }, )) .map(|val| val.0) @@ -87,11 +78,7 @@ impl<const R: bool> ClientDataJsonParser for RelaxedClientDataJsonParser<R> { serde_json::from_slice::<Chall>(json.split_at_checked(BOM.len()).map_or( json, |(bom, rem)| { - if bom == BOM { - rem - } else { - json - } + if bom == BOM { rem } else { json } }, )) .map(|c| c.0) @@ -226,7 +213,10 @@ impl<'de: 'a, 'a, const R: bool> Visitor<'de> for RelaxedHelper<'a, R> { impl Visitor<'_> for FieldVisitor { type Value = Field; fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { - write!(formatter, "'{TYPE}', '{CHALLENGE}', '{ORIGIN}', '{CROSS_ORIGIN}', or '{TOP_ORIGIN}'") + write!( + formatter, + "'{TYPE}', '{CHALLENGE}', '{ORIGIN}', '{CROSS_ORIGIN}', or '{TOP_ORIGIN}'" + ) } fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E> where @@ -524,8 +514,10 @@ mod tests { "topOrigin": "https://example.org" }) .to_string(); - assert!(RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) - .map_or(false, |c| !c.cross_origin)); + assert!( + RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) + .map_or(false, |c| !c.cross_origin) + ); // Missing `crossOrigin`. let input = serde_json::json!({ "challenge": "ABABABABABABABABABABAA", @@ -534,8 +526,10 @@ mod tests { "topOrigin": "https://example.org" }) .to_string(); - assert!(RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) - .map_or(false, |c| !c.cross_origin)); + assert!( + RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) + .map_or(false, |c| !c.cross_origin) + ); // `null` `topOrigin`. let input = serde_json::json!({ "challenge": "ABABABABABABABABABABAA", @@ -545,8 +539,10 @@ mod tests { "topOrigin": null }) .to_string(); - assert!(RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) - .map_or(false, |c| c.top_origin.is_none())); + assert!( + RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) + .map_or(false, |c| c.top_origin.is_none()) + ); // Missing `topOrigin`. let input = serde_json::json!({ "challenge": "ABABABABABABABABABABAA", @@ -555,8 +551,10 @@ mod tests { "crossOrigin": true, }) .to_string(); - assert!(RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) - .map_or(false, |c| c.top_origin.is_none())); + assert!( + RelaxedClientDataJsonParser::<true>::parse(input.as_bytes()) + .map_or(false, |c| c.top_origin.is_none()) + ); // `null` `challenge`. err = Error::invalid_type( Unexpected::Other("null"),