webauthn_rp

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

commit fcd94da3dcb4e3353a974c9973cad59a8b083dab
parent 88c3f257fcf796e6642a254adbdb3c81cfc214f4
Author: Zack Newman <zack@philomathiclife.com>
Date:   Tue,  3 Jun 2025 15:32:52 -0600

separate mediation into outer type. improve prf

Diffstat:
Msrc/hash.rs | 4++--
Msrc/lib.rs | 29++++++++++++++---------------
Msrc/request.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/request/auth.rs | 490++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/request/auth/ser.rs | 259++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/request/auth/ser_server_state.rs | 19++++++++++---------
Msrc/request/register.rs | 1133+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/request/register/error.rs | 4++--
Msrc/request/register/ser.rs | 404++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/request/register/ser_server_state.rs | 40+++++++++++++++++++++++++++++++++-------
Msrc/request/ser.rs | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/response.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/response/auth.rs | 105+++++++++++++++++++++++--------------------------------------------------------
Msrc/response/auth/error.rs | 3+++
Msrc/response/cbor.rs | 2++
Msrc/response/register.rs | 542++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/response/register/error.rs | 17+++++++++++++----
17 files changed, 2306 insertions(+), 1077 deletions(-)

diff --git a/src/hash.rs b/src/hash.rs @@ -7,7 +7,7 @@ use super::{ DiscoverableAuthenticationServerState, DiscoverableCredentialRequestOptions, NonDiscoverableAuthenticationServerState, NonDiscoverableCredentialRequestOptions, }, - register::{PublicKeyCredentialCreationOptions, RegistrationServerState}, + register::{CredentialCreationOptions, RegistrationServerState}, }, }; use core::hash::{BuildHasher, Hasher}; @@ -29,7 +29,7 @@ pub mod hash_set; /// 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`], +/// only constructable via [`CredentialCreationOptions::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 diff --git a/src/lib.rs b/src/lib.rs @@ -20,7 +20,7 @@ //! use core::convert; //! use webauthn_rp::{ //! AuthenticatedCredential64, DiscoverableAuthentication64, DiscoverableAuthenticationServerState, -//! DiscoverableCredentialRequestOptions, PublicKeyCredentialCreationOptions64, RegisteredCredential64, +//! DiscoverableCredentialRequestOptions, CredentialCreationOptions64, RegisteredCredential64, //! Registration, RegistrationServerState64, //! hash::hash_set::FixedCapHashSet, //! request::{ @@ -114,7 +114,7 @@ //! ); //! let user_id = UserHandle64::new(); //! let (server, client) = -//! PublicKeyCredentialCreationOptions64::first_passkey_with_blank_user_info( +//! CredentialCreationOptions64::first_passkey_with_blank_user_info( //! &rp_id, &user_id, //! ) //! .start_ceremony() @@ -175,7 +175,7 @@ //! .unwrap_or_else(|_e| unreachable!("example.com is a valid domain")), //! ); //! let (entity, creds) = select_user_info(user_id)?.ok_or_else(|| AppErr::NoAccount)?; -//! let (server, client) = PublicKeyCredentialCreationOptions64::passkey(&rp_id, entity, creds) +//! let (server, client) = CredentialCreationOptions64::passkey(&rp_id, entity, creds) //! .start_ceremony() //! .unwrap_or_else(|_e| { //! unreachable!("we don't manually mutate the options and we assume the server clock is functioning; thus this won't error") @@ -534,8 +534,8 @@ use crate::{ TimedCeremony, Url, auth::{AllowedCredential, AllowedCredentials, PublicKeyCredentialRequestOptions}, register::{ - CoseAlgorithmIdentifier, Nickname, PublicKeyCredentialUserEntity, UserHandle16, - UserHandle64, Username, + CoseAlgorithmIdentifier, Nickname, PublicKeyCredentialCreationOptions, + PublicKeyCredentialUserEntity, UserHandle16, UserHandle64, Username, }, }, response::{ @@ -644,11 +644,11 @@ pub mod hash; /// if encrypted is not desirable though. /// * [`Label 4`](#label4) is ideal as a single-factor flow incorporated within a wider multi-factor authentication (MFA) /// setup. The easiest way to register such a credential is with -/// [`PublicKeyCredentialCreationOptions::second_factor`]. +/// [`CredentialCreationOptions::second_factor`]. /// * [`Label 13`](#label13) is ideal for passkey setups as it allows for pleasant UX where a user does not have to type a /// username nor password while still being secured with MFA with one of the factors being based on public-key /// cryptography which for many is the most secure form of single-factor authentication. The easiest way to register -/// such a credential is with [`PublicKeyCredentialCreationOptions::passkey`]. +/// such a credential is with [`CredentialCreationOptions::passkey`]. /// /// Two other reasons one may prefer to construct client-side credentials is richer support for extensions (e.g., /// [`largeBlobKey`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-largeBlobKey-extension) @@ -658,8 +658,8 @@ pub mod hash; /// seamlessly transition from a username and password scheme to a userless and passwordless one in the future. /// /// Note the table is purely informative. While helper functions -/// (e.g., [`PublicKeyCredentialCreationOptions::passkey`]) only exist for [`Label 4`](#label4) and -/// [`Label 13`](#label13), one can create any credential since all fields in [`PublicKeyCredentialCreationOptions`] +/// (e.g., [`CredentialCreationOptions::passkey`]) only exist for [`Label 4`](#label4) and +/// [`Label 13`](#label13), one can create any credential since all fields in [`CredentialCreationOptions`] /// and [`PublicKeyCredentialRequestOptions`] are accessible. pub mod request; /// Functionality for completing ceremonies. @@ -675,10 +675,9 @@ pub use crate::{ NonDiscoverableAuthenticationServerState, NonDiscoverableCredentialRequestOptions, }, register::{ - PublicKeyCredentialCreationOptions, PublicKeyCredentialCreationOptions16, - PublicKeyCredentialCreationOptions64, RegistrationClientState, - RegistrationClientState16, RegistrationClientState64, RegistrationServerState, - RegistrationServerState16, RegistrationServerState64, + CredentialCreationOptions, CredentialCreationOptions16, CredentialCreationOptions64, + RegistrationClientState, RegistrationClientState16, RegistrationClientState64, + RegistrationServerState, RegistrationServerState16, RegistrationServerState64, }, }, response::{ @@ -834,7 +833,7 @@ fn verify_static_and_dynamic_state<T>( /// When registering a credential, [`AttestedCredentialData::aaguid`], [`AttestedCredentialData::credential_id`], /// and [`AttestedCredentialData::credential_public_key`] will be the sources for [`Metadata::aaguid`], /// [`Self::id`], and [`StaticState::credential_public_key`] respectively. The [`PublicKeyCredentialUserEntity::id`] -/// associated with the [`PublicKeyCredentialCreationOptions`] used to create the `RegisteredCredential` via +/// associated with the [`CredentialCreationOptions`] used to create the `RegisteredCredential` via /// [`RegistrationServerState::verify`] will be the source for [`Self::user_id`]. /// /// The only way to create this is via `RegistrationServerState::verify`. @@ -1131,7 +1130,7 @@ pub enum AggErr { RequestOptions(RequestOptionsErr), /// Variant when [`NonDiscoverableCredentialRequestOptions::second_factor`] errors. SecondFactor(SecondFactorErr), - /// Variant when [`PublicKeyCredentialCreationOptions::start_ceremony`] errors. + /// Variant when [`CredentialCreationOptions::start_ceremony`] errors. CreationOptions(CreationOptionsErr), /// Variant when [`Nickname::try_from`] errors. Nickname(NicknameErr), diff --git a/src/request.rs b/src/request.rs @@ -8,7 +8,7 @@ use super::{ NonDiscoverableAuthenticationServerState, NonDiscoverableCredentialRequestOptions, PublicKeyCredentialRequestOptions, }, - register::{PublicKeyCredentialCreationOptions, RegistrationServerState}, + register::{CredentialCreationOptions, RegistrationServerState}, }, response::register::ClientExtensionsOutputs, }; @@ -108,7 +108,7 @@ pub mod error; /// # hash::hash_set::FixedCapHashSet, /// # request::{ /// # register::{ -/// # PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle, USER_HANDLE_MAX_LEN, UserHandle64, +/// # CredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle, USER_HANDLE_MAX_LEN, UserHandle64, /// # }, /// # AsciiDomain, PublicKeyCredentialDescriptor, RpId /// # }, @@ -125,7 +125,7 @@ pub mod error; /// # #[cfg(feature = "custom")] /// let creds = get_registered_credentials(&user_handle)?; /// # #[cfg(feature = "custom")] -/// let (server, client) = PublicKeyCredentialCreationOptions::passkey(&rp_id, user.clone(), creds) +/// let (server, client) = CredentialCreationOptions::passkey(&rp_id, user.clone(), creds) /// .start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( @@ -137,7 +137,7 @@ pub mod error; /// let creds_2 = get_registered_credentials(&user_handle)?; /// # #[cfg(feature = "custom")] /// let (server_2, client_2) = -/// PublicKeyCredentialCreationOptions::second_factor(&rp_id, user, creds_2).start_ceremony()?; +/// CredentialCreationOptions::second_factor(&rp_id, user, creds_2).start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( /// ceremonies.insert_remove_all_expired(server_2).map_or(false, convert::identity) @@ -1071,19 +1071,20 @@ impl Display for ExtensionInfo { } } /// [`CredentialMediationRequirement`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Default)] pub enum CredentialMediationRequirement { /// [`silent`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-silent). Silent, - /// [`optional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-optional) + /// [`optional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-optional). + #[default] Optional, - /// [`conditional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-conditional) + /// [`conditional`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-conditional). /// - /// Note that when registering a new credential with [`PublicKeyCredentialCreationOptions::mediation`] set to + /// Note that when registering a new credential with [`CredentialCreationOptions::mediation`] set to /// `Self::Conditional`, [`UserVerificationRequirement::Discouraged`] MUST be used unless user verification /// can be explicitly performed during the ceremony. Conditional, - /// [`required`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-required) + /// [`required`](https://www.w3.org/TR/credential-management-1/#dom-credentialmediationrequirement-required). Required, } #[cfg(test)] @@ -1100,15 +1101,18 @@ impl PartialEq for CredentialMediationRequirement { /// Backup requirements for the credential. #[derive(Clone, Copy, Debug, Default)] pub enum BackupReq { - #[default] /// No requirements (i.e., any [`Backup`] is allowed). + #[default] None, /// Credential must not be eligible for backup. NotEligible, /// Credential must be eligible for backup. /// - /// Note the existence of a backup is ignored. If a backup must exist, then use [`Self::Exists`]. + /// Note the existence of a backup is ignored. If a backup must exist, then use [`Self::Exists`]; if a + /// backup must not exist, then use [`Self::EligibleNotExists`]. Eligible, + /// Credential must be eligible for backup, but a backup must not exist. + EligibleNotExists, /// Credential must be backed up. Exists, } @@ -1465,6 +1469,13 @@ trait Ceremony<const USER_LEN: usize, const DISCOVERABLE: bool> { Ok(()) } } + BackupReq::EligibleNotExists => { + if matches!(flag.backup, Backup::Eligible) { + Ok(()) + } else { + Err(CeremonyErr::BackupExists) + } + } BackupReq::Exists => { if matches!(flag.backup, Backup::Exists) { Ok(()) @@ -1506,6 +1517,20 @@ pub trait TimedCeremony { #[cfg(all(not(doc), feature = "serializable_server_state"))] fn expiration(&self) -> SystemTime; } +/// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). +#[derive(Clone, Copy, Debug)] +pub struct PrfInput<'first, 'second> { + /// [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first). + pub first: &'first [u8], + /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second). + pub second: Option<&'second [u8]>, +} +#[cfg(test)] +impl PartialEq for PrfInput<'_, '_> { + fn eq(&self, other: &Self) -> bool { + self.first == other.first && self.second == other.second + } +} #[cfg(test)] mod tests { #[cfg(feature = "custom")] @@ -1527,7 +1552,7 @@ mod tests { }, }, }, - AsciiDomain, Challenge, Credentials, ExtensionInfo, ExtensionReq, + AsciiDomain, Challenge, Credentials, ExtensionInfo, ExtensionReq, PrfInput, PublicKeyCredentialDescriptor, RpId, UserVerificationRequirement, auth::{ AllowedCredential, AllowedCredentials, AuthenticationVerificationOptions, @@ -1535,7 +1560,7 @@ mod tests { Extension as AuthExt, NonDiscoverableCredentialRequestOptions, PrfInputOwned, }, register::{ - CredProtect, Extension as RegExt, FourToSixtyThree, PublicKeyCredentialCreationOptions, + CredProtect, CredentialCreationOptions, Extension as RegExt, FourToSixtyThree, PublicKeyCredentialUserEntity, RegistrationVerificationOptions, UserHandle, }, }; @@ -1575,7 +1600,7 @@ mod tests { 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( + let mut opts = CredentialCreationOptions::passkey( &rp_id, PublicKeyCredentialUserEntity { name: "foo".try_into()?, @@ -1584,8 +1609,8 @@ mod tests { }, Vec::new(), ); - opts.challenge = Challenge(0); - opts.extensions = RegExt { + opts.public_key.challenge = Challenge(0); + opts.public_key.extensions = RegExt { cred_props: None, cred_protect: CredProtect::UserVerificationRequired( false, @@ -1596,7 +1621,13 @@ mod tests { .unwrap_or_else(|| unreachable!("bug in FourToSixyThree::new")), ExtensionInfo::RequireEnforceValue, )), - prf: Some(ExtensionInfo::RequireEnforceValue), + prf: Some(( + PrfInput { + first: [0].as_slice(), + second: None, + }, + ExtensionInfo::RequireEnforceValue, + )), }; let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAttestation::new`] for more information. @@ -1934,7 +1965,7 @@ mod tests { prf: Some(PrfInputOwned { first: Vec::new(), second: Some(Vec::new()), - ext_info: ExtensionReq::Require, + ext_req: ExtensionReq::Require, }), }, }); @@ -2140,7 +2171,7 @@ mod tests { 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( + let mut opts = CredentialCreationOptions::passkey( &rp_id, PublicKeyCredentialUserEntity { name: "foo".try_into()?, @@ -2149,7 +2180,7 @@ mod tests { }, Vec::new(), ); - opts.challenge = Challenge(0); + opts.public_key.challenge = Challenge(0); let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAttestation::new`] for more information. let mut attestation_object = Vec::with_capacity(210); @@ -2398,7 +2429,7 @@ mod tests { 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); + opts.public_key.challenge = Challenge(0); let client_data_json = br#"{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAssertion::new`] for more information. let mut authenticator_data = Vec::with_capacity(69); @@ -2509,7 +2540,7 @@ mod tests { 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( + let mut opts = CredentialCreationOptions::passkey( &rp_id, PublicKeyCredentialUserEntity { name: "foo".try_into()?, @@ -2518,7 +2549,7 @@ mod tests { }, Vec::new(), ); - opts.challenge = Challenge(0); + opts.public_key.challenge = Challenge(0); let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAttestation::new`] for more information. let mut attestation_object = Vec::with_capacity(243); @@ -2802,7 +2833,7 @@ mod tests { 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); + opts.public_key.challenge = Challenge(0); let client_data_json = br#"{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAssertion::new`] for more information. let mut authenticator_data = Vec::with_capacity(69); @@ -2914,7 +2945,7 @@ mod tests { 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( + let mut opts = CredentialCreationOptions::passkey( &rp_id, PublicKeyCredentialUserEntity { name: "foo".try_into()?, @@ -2923,7 +2954,7 @@ mod tests { }, Vec::new(), ); - opts.challenge = Challenge(0); + opts.public_key.challenge = Challenge(0); let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAttestation::new`] for more information. let mut attestation_object = Vec::with_capacity(406); @@ -3416,7 +3447,7 @@ mod tests { 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); + opts.public_key.challenge = Challenge(0); let client_data_json = br#"{"type":"webauthn.get","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAssertion::new`] for more information. let mut authenticator_data = Vec::with_capacity(69); diff --git a/src/request/auth.rs b/src/request/auth.rs @@ -24,9 +24,9 @@ use super::{ register::{CompressedPubKey, CredentialProtectionPolicy}, }, }, - BackupReq, Ceremony, CeremonyOptions, Challenge, CredentialId, Credentials, ExtensionReq, Hint, - Origin, PublicKeyCredentialDescriptor, RpId, SentChallenge, THREE_HUNDRED_THOUSAND, - TimedCeremony, UserVerificationRequirement, + BackupReq, Ceremony, CeremonyOptions, Challenge, CredentialId, CredentialMediationRequirement, + Credentials, ExtensionReq, Hint, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, + SentChallenge, THREE_HUNDRED_THOUSAND, TimedCeremony, UserVerificationRequirement, auth::error::{RequestOptionsErr, SecondFactorErr}, }; use core::{ @@ -85,45 +85,6 @@ impl SignatureCounterEnforcement { } } } -/// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). -/// -/// This is only applicable if -/// [`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 -/// passed back in an effort to ensure sensitive data remains client-side. This means -/// [`prf`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfoutputs) must not exist, -/// be `null`, or be an -/// [`AuthenticationExtensionsPRFOutputs`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfoutputs) -/// such that [`results`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfoutputs-results) does not exist, -/// is `null`, or is 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`. -/// -/// For the owned analog, see [`PrfInputOwned`]. -/// -/// 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)] -pub struct PrfInput<'a> { - /// [`first`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-first). - pub first: &'a [u8], - /// [`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 @@ -134,21 +95,25 @@ pub struct PrfInputOwned { pub first: Vec<u8>, /// [`second`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfvalues-second). pub second: Option<Vec<u8>>, - /// /// 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, + pub ext_req: ExtensionReq, } /// The [defined extensions](https://www.w3.org/TR/webauthn-3/#sctn-defined-extensions) to send to the client. #[derive(Clone, Copy, Debug, Default)] -pub struct Extension<'prf> { +pub struct Extension<'prf_first, 'prf_second> { /// [`prf`](https://www.w3.org/TR/webauthn-3/#prf-extension). /// /// If both [`CredentialSpecificExtension::prf`] and this are [`Some`], then `CredentialSpecificExtension::prf` /// takes priority. - pub prf: Option<PrfInput<'prf>>, + /// + /// Note `ExtensionReq` 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 prf: Option<(PrfInput<'prf_first, 'prf_second>, ExtensionReq)>, } /// 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. @@ -325,18 +290,18 @@ impl From<Vec<PublicKeyCredentialDescriptor<Vec<u8>>>> for AllowedCredentials { } /// Helper that verifies the overlap of [`DiscoverableCredentialRequestOptions::start_ceremony`] and /// [`DiscoverableAuthenticationServerState::decode`]. -fn validate_discoverable_options_helper( +const fn validate_discoverable_options_helper( ext: ServerExtensionInfo, uv: UserVerificationRequirement, ) -> Result<(), RequestOptionsErr> { // If PRF is set, the user has to verify themselves. - ext.prf.as_ref().map_or(Ok(()), |_| { - if matches!(uv, UserVerificationRequirement::Required) { - Ok(()) - } else { - Err(RequestOptionsErr::PrfWithoutUserVerification) - } - }) + if matches!(ext.prf, ServerPrfInfo::One(_) | ServerPrfInfo::Two(_)) + && !matches!(uv, UserVerificationRequirement::Required) + { + Err(RequestOptionsErr::PrfWithoutUserVerification) + } else { + Ok(()) + } } /// Helper that verifies the overlap of [`NonDiscoverableCredentialRequestOptions::start_ceremony`] and /// [`NonDiscoverableAuthenticationServerState::decode`]. @@ -346,34 +311,40 @@ fn validate_non_discoverable_options_helper( ) -> Result<(), RequestOptionsErr> { creds.iter().try_fold((), |(), cred| { // If PRF is set, the user has to verify themselves. - cred.ext.prf.as_ref().map_or(Ok(()), |_| { - if matches!(uv, UserVerificationRequirement::Required) { - Ok(()) - } else { - Err(RequestOptionsErr::PrfWithoutUserVerification) - } - }) + if matches!(cred.ext.prf, ServerPrfInfo::One(_) | ServerPrfInfo::Two(_)) + && !matches!(uv, UserVerificationRequirement::Required) + { + Err(RequestOptionsErr::PrfWithoutUserVerification) + } else { + Ok(()) + } }) } -/// The [`PublicKeyCredentialRequestOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions) -/// to send to the client when authenticating a credential. +/// The [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions) +/// to send to the client when authenticating a discoverable credentential. /// /// Upon saving the [`DiscoverableAuthenticationServerState`] returned from [`Self::start_ceremony`], one MUST send /// [`DiscoverableAuthenticationClientState`] to the client ASAP. After receiving the newly created /// [`DiscoverableAuthentication`], it is validated using [`DiscoverableAuthenticationServerState::verify`]. #[derive(Debug)] -pub struct DiscoverableCredentialRequestOptions<'rp_id, 'prf>( - pub PublicKeyCredentialRequestOptions<'rp_id, 'prf>, -); -impl<'rp_id, 'prf> DiscoverableCredentialRequestOptions<'rp_id, 'prf> { - /// Creates a `DiscoverableCredentialRequestOptions` containing [`PublicKeyCredentialRequestOptions::passkey`]. +pub struct DiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { + /// [`mediation`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). + pub mediation: CredentialMediationRequirement, + /// `public-key` [credential type](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry). + pub public_key: PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, +} +impl<'rp_id, 'prf_first, 'prf_second> + DiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> +{ + /// Creates a `DiscoverableCredentialRequestOptions` containing [`CredentialMediationRequirement::default`] and + /// [`PublicKeyCredentialRequestOptions::passkey`]. /// /// # Examples /// /// ``` /// # use webauthn_rp::request::{auth::DiscoverableCredentialRequestOptions, AsciiDomain, RpId, UserVerificationRequirement}; /// assert!(matches!( - /// DiscoverableCredentialRequestOptions::passkey(&RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?)).0.user_verification, + /// DiscoverableCredentialRequestOptions::passkey(&RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?)).public_key.user_verification, /// UserVerificationRequirement::Required /// )); /// # Ok::<_, webauthn_rp::AggErr>(()) @@ -381,7 +352,10 @@ impl<'rp_id, 'prf> DiscoverableCredentialRequestOptions<'rp_id, 'prf> { #[inline] #[must_use] pub fn passkey<'a: 'rp_id>(rp_id: &'a RpId) -> Self { - Self(PublicKeyCredentialRequestOptions::passkey(rp_id)) + Self { + mediation: CredentialMediationRequirement::default(), + public_key: PublicKeyCredentialRequestOptions::passkey(rp_id), + } } /// Begins the [authentication ceremony](https://www.w3.org/TR/webauthn-3/#authentication-ceremony) consuming /// `self`. Note that the expiration [`Instant`]/[`SystemTime`] is saved, so @@ -398,51 +372,63 @@ impl<'rp_id, 'prf> DiscoverableCredentialRequestOptions<'rp_id, 'prf> { ) -> Result< ( DiscoverableAuthenticationServerState, - DiscoverableAuthenticationClientState<'rp_id, 'prf>, + DiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second>, ), RequestOptionsErr, > { - let extensions = self.0.extensions.into(); - validate_discoverable_options_helper(extensions, self.0.user_verification).and_then(|()| { - #[cfg(not(feature = "serializable_server_state"))] - let res = Instant::now(); - #[cfg(feature = "serializable_server_state")] - let res = SystemTime::now(); - res.checked_add(Duration::from_millis( - NonZeroU64::from(self.0.timeout).get(), - )) - .ok_or(RequestOptionsErr::InvalidTimeout) - .map(|expiration| { - ( - DiscoverableAuthenticationServerState(AuthenticationServerState { - challenge: SentChallenge(self.0.challenge.0), - user_verification: self.0.user_verification, - extensions, - expiration, - }), - DiscoverableAuthenticationClientState(self), - ) + let extensions = self.public_key.extensions.into(); + validate_discoverable_options_helper(extensions, self.public_key.user_verification) + .and_then(|()| { + #[cfg(not(feature = "serializable_server_state"))] + let res = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let res = SystemTime::now(); + res.checked_add(Duration::from_millis( + NonZeroU64::from(self.public_key.timeout).get(), + )) + .ok_or(RequestOptionsErr::InvalidTimeout) + .map(|expiration| { + ( + DiscoverableAuthenticationServerState(AuthenticationServerState { + challenge: SentChallenge(self.public_key.challenge.0), + user_verification: self.public_key.user_verification, + extensions, + expiration, + }), + DiscoverableAuthenticationClientState(self), + ) + }) }) - }) } } -/// The [`PublicKeyCredentialRequestOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions) -/// to send to the client when authenticating a credential. +/// The [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions) +/// to send to the client when authenticating non-discoverable credententials. /// /// Upon saving the [`NonDiscoverableAuthenticationServerState`] returned from [`Self::start_ceremony`], one MUST send /// [`NonDiscoverableAuthenticationClientState`] to the client ASAP. After receiving the newly created /// [`NonDiscoverableAuthentication`], it is validated using [`NonDiscoverableAuthenticationServerState::verify`]. #[derive(Debug)] -pub struct NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { +pub struct NonDiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { + /// [`mediation`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). + mediation: CredentialMediationRequirement, /// [`PublicKeyCredentialRequestOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptions). - options: PublicKeyCredentialRequestOptions<'rp_id, 'prf>, + options: PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, /// [`allowCredentials`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials). allow_credentials: AllowedCredentials, } -impl<'rp_id, 'prf> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { +impl<'rp_id, 'prf_first, 'prf_second> + NonDiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> +{ + /// Returns a mutable reference to the `CredentialMediationRequirement`. + #[inline] + pub const fn mediation(&mut self) -> &mut CredentialMediationRequirement { + &mut self.mediation + } /// Returns a mutable reference to the configurable options. #[inline] - pub const fn options(&mut self) -> &mut PublicKeyCredentialRequestOptions<'rp_id, 'prf> { + pub const fn options( + &mut self, + ) -> &mut PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { &mut self.options } /// Returns a reference to the [`AllowedCredential`]s. @@ -452,7 +438,8 @@ impl<'rp_id, 'prf> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { &self.allow_credentials } /// Creates a `NonDiscoverableCredentialRequestOptions` containing - /// [`PublicKeyCredentialRequestOptions::second_factor`] and the passed [`AllowedCredentials`]. + /// [`CredentialMediationRequirement::Optional`], + /// [`PublicKeyCredentialRequestOptions::second_factor`], and the passed [`AllowedCredentials`]. /// /// # Errors /// @@ -506,6 +493,7 @@ impl<'rp_id, 'prf> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { Err(SecondFactorErr) } else { Ok(Self { + mediation: CredentialMediationRequirement::default(), options: PublicKeyCredentialRequestOptions::second_factor(rp_id), allow_credentials, }) @@ -526,7 +514,7 @@ impl<'rp_id, 'prf> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { ) -> Result< ( NonDiscoverableAuthenticationServerState, - NonDiscoverableAuthenticationClientState<'rp_id, 'prf>, + NonDiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second>, ), RequestOptionsErr, > { @@ -571,7 +559,7 @@ impl<'rp_id, 'prf> NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { /// /// This does _not_ contain [`allowCredentials`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-allowcredentials). #[derive(Debug)] -pub struct PublicKeyCredentialRequestOptions<'rp_id, 'prf> { +pub struct PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { /// [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-challenge). pub challenge: Challenge, /// [`timeout`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-timeout). @@ -589,9 +577,9 @@ pub struct PublicKeyCredentialRequestOptions<'rp_id, 'prf> { /// [`hints`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-hints). pub hints: Hint, /// [`extensions`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-extensions). - pub extensions: Extension<'prf>, + pub extensions: Extension<'prf_first, 'prf_second>, } -impl<'rp_id> PublicKeyCredentialRequestOptions<'rp_id, '_> { +impl<'rp_id> PublicKeyCredentialRequestOptions<'rp_id, '_, '_> { /// Creates a `PublicKeyCredentialRequestOptions` with [`Self::user_verification`] set to /// [`UserVerificationRequirement::Required`] and [`Self::timeout`] set to 5 minutes, /// @@ -647,28 +635,36 @@ impl<'rp_id> PublicKeyCredentialRequestOptions<'rp_id, '_> { /// Container of a [`DiscoverableCredentialRequestOptions`] that has been used to start the authentication ceremony. /// This gets sent to the client ASAP. #[derive(Debug)] -pub struct DiscoverableAuthenticationClientState<'rp_id, 'prf>( - DiscoverableCredentialRequestOptions<'rp_id, 'prf>, +pub struct DiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second>( + DiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, ); -impl<'rp_id, 'prf> DiscoverableAuthenticationClientState<'rp_id, 'prf> { +impl<'rp_id, 'prf_first, 'prf_second> + DiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second> +{ /// Returns the `DiscoverableCredentialRequestOptions` that was used to start an authentication ceremony. #[inline] #[must_use] - pub const fn options(&self) -> &DiscoverableCredentialRequestOptions<'rp_id, 'prf> { + pub const fn options( + &self, + ) -> &DiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { &self.0 } } /// Container of a [`NonDiscoverableCredentialRequestOptions`] that has been used to start the authentication /// ceremony. This gets sent to the client ASAP. #[derive(Debug)] -pub struct NonDiscoverableAuthenticationClientState<'rp_id, 'prf>( - NonDiscoverableCredentialRequestOptions<'rp_id, 'prf>, +pub struct NonDiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second>( + NonDiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, ); -impl<'rp_id, 'prf> NonDiscoverableAuthenticationClientState<'rp_id, 'prf> { +impl<'rp_id, 'prf_first, 'prf_second> + NonDiscoverableAuthenticationClientState<'rp_id, 'prf_first, 'prf_second> +{ /// Returns the `NonDiscoverableCredentialRequestOptions` that was used to start an authentication ceremony. #[inline] #[must_use] - pub const fn options(&self) -> &NonDiscoverableCredentialRequestOptions<'rp_id, 'prf> { + pub const fn options( + &self, + ) -> &NonDiscoverableCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second> { &self.0 } } @@ -705,18 +701,14 @@ impl CredPrf { /// [`DiscoverableAuthenticationServerState`] in an in-memory collection. #[derive(Clone, Copy, Debug)] enum ServerPrfInfo { + /// No `PrfInput`. + None, /// `PrfInput::second` was `None`. One(ExtensionReq), /// `PrfInput::second` was `Some`. Two(ExtensionReq), } impl ServerPrfInfo { - /// Returns the `ExtensionReq` sent to the client. - const fn ext_info(self) -> ExtensionReq { - match self { - Self::One(info) | Self::Two(info) => info, - } - } /// Validates `val` based on the passed arguments. /// /// It's not possible to request the PRF extension without sending `UserVerificationRequirement::Required`; @@ -724,33 +716,36 @@ impl ServerPrfInfo { /// 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>, + const fn validate( + self, user_verified: bool, cred_prf: CredPrf, hmac: HmacSecret, err_unsolicited: bool, ) -> Result<(), ExtensionErr> { match hmac { - 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 => { + HmacSecret::None => match self { + Self::None => Ok(()), + Self::One(req) | Self::Two(req) => { + if matches!(req, ExtensionReq::Allow) { + if cred_prf.is_prf_capable() { + Ok(()) + } else { Err(ExtensionErr::PrfRequestedForPrfIncapableCred) } - CredPrf::TrueTrueHmac => Err(ExtensionErr::MissingHmacSecret), + } 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( - || { + }, + HmacSecret::One => match self { + Self::None => { if err_unsolicited { Err(ExtensionErr::ForbiddenHmacSecret) } else if cred_prf.is_prf_capable() { @@ -762,24 +757,21 @@ impl ServerPrfInfo { } else { Err(ExtensionErr::PrfRequestedForPrfIncapableCred) } - }, - |info| { - if matches!(info, Self::One(_)) { - if cred_prf.is_prf_capable() { - Ok(()) - } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) - } + } + Self::One(_) => { + if cred_prf.is_prf_capable() { + Ok(()) } else { - Err(ExtensionErr::InvalidHmacSecretValue( - OneOrTwo::Two, - OneOrTwo::One, - )) + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) } - }, - ), - HmacSecret::Two => val.map_or_else( - || { + } + Self::Two(_) => Err(ExtensionErr::InvalidHmacSecretValue( + OneOrTwo::Two, + OneOrTwo::One, + )), + }, + HmacSecret::Two => match self { + Self::None => { if err_unsolicited { Err(ExtensionErr::ForbiddenHmacSecret) } else if cred_prf.is_prf_capable() { @@ -791,22 +783,19 @@ impl ServerPrfInfo { } else { Err(ExtensionErr::PrfRequestedForPrfIncapableCred) } - }, - |info| { - if matches!(info, Self::Two(_)) { - if cred_prf.is_prf_capable() { - Ok(()) - } else { - Err(ExtensionErr::PrfRequestedForPrfIncapableCred) - } + } + Self::One(_) => Err(ExtensionErr::InvalidHmacSecretValue( + OneOrTwo::One, + OneOrTwo::Two, + )), + Self::Two(_) => { + if cred_prf.is_prf_capable() { + Ok(()) } else { - Err(ExtensionErr::InvalidHmacSecretValue( - OneOrTwo::One, - OneOrTwo::Two, - )) + Err(ExtensionErr::PrfRequestedForPrfIncapableCred) } - }, - ), + } + }, } } } @@ -814,16 +803,19 @@ impl ServerPrfInfo { impl PartialEq for ServerPrfInfo { fn eq(&self, other: &Self) -> bool { match *self { + Self::None => matches!(*other, Self::None), Self::One(req) => matches!(*other, Self::One(req2) if req == req2), Self::Two(req) => matches!(*other, Self::Two(req2) if req == req2), } } } -impl From<PrfInput<'_>> for ServerPrfInfo { - fn from(value: PrfInput<'_>) -> Self { - value - .second - .map_or_else(|| Self::One(value.ext_info), |_| Self::Two(value.ext_info)) +impl From<Option<(PrfInput<'_, '_>, ExtensionReq)>> for ServerPrfInfo { + fn from(value: Option<(PrfInput<'_, '_>, ExtensionReq)>) -> Self { + value.map_or(Self::None, |val| { + val.0 + .second + .map_or_else(|| Self::One(val.1), |_| Self::Two(val.1)) + }) } } impl From<&PrfInputOwned> for ServerPrfInfo { @@ -831,7 +823,7 @@ impl From<&PrfInputOwned> for ServerPrfInfo { value .second .as_ref() - .map_or_else(|| Self::One(value.ext_info), |_| Self::Two(value.ext_info)) + .map_or_else(|| Self::One(value.ext_req), |_| Self::Two(value.ext_req)) } } /// `Extension` without the actual data sent to reduce memory usage when storing [`AuthenticationServerState`] @@ -839,12 +831,12 @@ impl From<&PrfInputOwned> for ServerPrfInfo { #[derive(Clone, Copy, Debug)] struct ServerExtensionInfo { /// `Extension::prf`. - prf: Option<ServerPrfInfo>, + prf: ServerPrfInfo, } -impl From<Extension<'_>> for ServerExtensionInfo { - fn from(value: Extension<'_>) -> Self { +impl From<Extension<'_, '_>> for ServerExtensionInfo { + fn from(value: Extension<'_, '_>) -> Self { Self { - prf: value.prf.map(ServerPrfInfo::from), + prf: value.prf.into(), } } } @@ -859,7 +851,7 @@ impl PartialEq for ServerExtensionInfo { #[derive(Clone, Copy, Debug)] struct ServerCredSpecificExtensionInfo { /// `CredentialSpecificExtension::prf`. - prf: Option<ServerPrfInfo>, + prf: ServerPrfInfo, } #[cfg(test)] impl PartialEq for ServerCredSpecificExtensionInfo { @@ -870,7 +862,10 @@ impl PartialEq for ServerCredSpecificExtensionInfo { impl From<&CredentialSpecificExtension> for ServerCredSpecificExtensionInfo { fn from(value: &CredentialSpecificExtension) -> Self { Self { - prf: value.prf.as_ref().map(ServerPrfInfo::from), + prf: value + .prf + .as_ref() + .map_or(ServerPrfInfo::None, ServerPrfInfo::from), } } } @@ -878,7 +873,7 @@ impl ServerExtensionInfo { /// Validates the extensions. /// /// Note that this MUST only be called internally by `auth::validate_extensions`. - fn validate_extensions( + const fn validate_extensions( self, user_verified: bool, auth_ext: AuthenticatorExtensionOutput, @@ -905,31 +900,26 @@ fn validate_extensions( ) -> Result<(), ExtensionErr> { cred_ext.map_or_else( || { - // No client-specific extensions, so we can simply focus on `ext`. + // No credental-specific extensions, so we can simply focus on `ext`. 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. - c_ext.prf.as_ref().map_or_else( - || { - ServerPrfInfo::validate( - ext.prf, - user_verified, - cred_prf, - auth_ext.hmac_secret, - error_unsolicited, - ) - }, - |_| { - ServerPrfInfo::validate( - c_ext.prf, - user_verified, - cred_prf, - auth_ext.hmac_secret, - error_unsolicited, - ) - }, - ) + if matches!(c_ext.prf, ServerPrfInfo::None) { + ext.prf.validate( + user_verified, + cred_prf, + auth_ext.hmac_secret, + error_unsolicited, + ) + } else { + c_ext.prf.validate( + user_verified, + cred_prf, + auth_ext.hmac_secret, + error_unsolicited, + ) + } }, ) } @@ -1717,7 +1707,7 @@ mod tests { const CBOR_MAP: u8 = 0b101_00000; #[test] #[cfg(all(feature = "custom", feature = "serializable_server_state"))] - fn ed25519_auth_ser() -> Result<(), AggErr> { + fn eddsa_auth_ser() -> Result<(), AggErr> { let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); let mut creds = AllowedCredentials::with_capacity(1); creds.push(AllowedCredential { @@ -1729,7 +1719,7 @@ mod tests { prf: Some(PrfInputOwned { first: Vec::new(), second: Some(Vec::new()), - ext_info: ExtensionReq::Require, + ext_req: ExtensionReq::Require, }), }, }); @@ -1897,8 +1887,8 @@ mod tests { )?) ); let mut opts_2 = DiscoverableCredentialRequestOptions::passkey(&rp_id); - opts_2.0.challenge = Challenge(0); - opts_2.0.extensions = Extension { prf: None }; + opts_2.public_key.challenge = Challenge(0); + opts_2.public_key.extensions = Extension { prf: None }; let server_2 = opts_2.start_ceremony()?.0; assert!( server_2.is_eq(&DiscoverableAuthenticationServerState::decode( @@ -1937,7 +1927,7 @@ mod tests { /// `true` iff `UserVerificationRequirement::Required` should be used; otherwise /// `UserVerificationRequirement::Preferred` is used. None(bool), - Prf(PrfInput<'static>), + Prf((PrfInput<'static, 'static>, ExtensionReq)), } #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] #[derive(Clone, Copy)] @@ -2119,17 +2109,17 @@ mod tests { client_data_json_relaxed: false, }; let mut opts = DiscoverableCredentialRequestOptions::passkey(&rp_id); - opts.0.challenge = Challenge(0); - opts.0.user_verification = UserVerificationRequirement::Preferred; + opts.public_key.challenge = Challenge(0); + opts.public_key.user_verification = UserVerificationRequirement::Preferred; match options.request.prf_uv { PrfUvOptions::None(required) => { if required { - opts.0.user_verification = UserVerificationRequirement::Required; + opts.public_key.user_verification = UserVerificationRequirement::Required; }; } PrfUvOptions::Prf(input) => { - opts.0.user_verification = UserVerificationRequirement::Required; - opts.0.extensions.prf = Some(input); + opts.public_key.user_verification = UserVerificationRequirement::Required; + opts.public_key.extensions.prf = Some(input); } } let mut cred = AuthenticatedCredential::new( @@ -2197,26 +2187,34 @@ mod tests { 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, - }), + PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: None, + }, + ExtensionReq::Require, + )), + PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: None, + }, + ExtensionReq::Allow, + )), + PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: Some([].as_slice()), + }, + ExtensionReq::Require, + )), + PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: Some([].as_slice()), + }, + ExtensionReq::Allow, + )), ]; for cred_protect in ALL_CRED_PROTECT_OPTIONS { for prf in ALL_PRF_CRED_OPTIONS { @@ -2286,11 +2284,13 @@ mod tests { let mut opts = TestOptions { request: TestRequestOptions { error_unsolicited: false, - prf_uv: PrfUvOptions::Prf(PrfInput { - first: [].as_slice(), - second: None, - ext_info: ExtensionReq::Allow, - }), + prf_uv: PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: None, + }, + ExtensionReq::Allow, + )), }, response: TestResponseOptions { user_verified: true, @@ -2302,19 +2302,23 @@ mod tests { }, }; validate(opts)?; - opts.request.prf_uv = PrfUvOptions::Prf(PrfInput { - first: [].as_slice(), - second: None, - ext_info: ExtensionReq::Require, - }); + opts.request.prf_uv = PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: None, + }, + 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.request.prf_uv = PrfUvOptions::Prf(( + PrfInput { + first: [].as_slice(), + second: None, + }, + ExtensionReq::Allow, + )); opts.cred.prf = PrfCredOptions::TrueNoHmac; validate(opts)?; opts.response.hmac = HmacSecret::Two; diff --git a/src/request/auth/ser.rs b/src/request/auth/ser.rs @@ -1,56 +1,10 @@ use super::{ AllowedCredential, AllowedCredentials, Credentials as _, DiscoverableAuthenticationClientState, - Extension, NonDiscoverableAuthenticationClientState, PrfInput, PrfInputOwned, + DiscoverableCredentialRequestOptions, Extension, NonDiscoverableAuthenticationClientState, + NonDiscoverableCredentialRequestOptions, PrfInput, PrfInputOwned, PublicKeyCredentialRequestOptions, }; -use data_encoding::BASE64URL_NOPAD; use serde::ser::{Serialize, SerializeMap as _, SerializeStruct as _, Serializer}; -impl Serialize for PrfInput<'_> { - /// Serializes `self` to conform with - /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). - /// - /// # Examples - /// - /// ``` - /// # use webauthn_rp::request::{auth::PrfInput, ExtensionReq}; - /// assert_eq!( - /// serde_json::to_string(&PrfInput { - /// first: [0; 4].as_slice(), - /// second: Some([2; 1].as_slice()), - /// ext_info: ExtensionReq::Require - /// })?, - /// r#"{"first":"AAAAAA","second":"Ag"}"# - /// ); - /// # Ok::<_, serde_json::Error>(()) - /// ``` - #[expect( - clippy::arithmetic_side_effects, - reason = "comment justifies how overflow is not possible" - )] - #[inline] - fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer - // The max value is 1 + 1 = 2, so overflow is not an issue. - .serialize_struct("PrfInput", 1 + usize::from(self.second.is_some())) - .and_then(|mut ser| { - ser.serialize_field("first", BASE64URL_NOPAD.encode(self.first).as_str()) - .and_then(|()| { - self.second - .as_ref() - .map_or(Ok(()), |second| { - ser.serialize_field( - "second", - BASE64URL_NOPAD.encode(second).as_str(), - ) - }) - .and_then(|()| ser.end()) - }) - }) - } -} impl Serialize for PrfInputOwned { /// See [`PrfInput::serialize`] #[inline] @@ -61,7 +15,6 @@ impl Serialize for PrfInputOwned { PrfInput { first: self.first.as_slice(), second: self.second.as_deref(), - ext_info: self.ext_info, } .serialize(serializer) } @@ -179,13 +132,13 @@ impl Serialize for PrfCreds<'_> { } } /// [`AuthenticationExtensionsPRFInputs`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfinputs). -struct PrfInputs<'a, 'b> { +struct PrfInputs<'a, 'b, 'c> { /// [`eval`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfinputs-eval). - eval: Option<PrfInput<'a>>, + eval: Option<PrfInput<'a, 'b>>, /// [`evalByCredential`](https://www.w3.org/TR/webauthn-3/#dom-authenticationextensionsprfinputs-evalbycredential). - eval_by_credential: PrfCreds<'b>, + eval_by_credential: PrfCreds<'c>, } -impl Serialize for PrfInputs<'_, '_> { +impl Serialize for PrfInputs<'_, '_, '_> { #[expect( clippy::arithmetic_side_effects, reason = "comment explains how overflow is not possible" @@ -217,15 +170,15 @@ impl Serialize for PrfInputs<'_, '_> { } /// Serializes `self` to conform with /// [`AuthenticationExtensionsClientInputsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsclientinputsjson). -struct ExtensionHelper<'a, 'b> { +struct ExtensionHelper<'a, 'b, 'c> { /// [`extension`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptionsjson-extensions). - extension: &'a Extension<'b>, + extension: &'a Extension<'b, 'c>, /// [`extension`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptionsjson-extensions). /// /// Some extensions contain records, so we need both this and above. allow_credentials: &'a AllowedCredentials, } -impl Serialize for ExtensionHelper<'_, '_> { +impl Serialize for ExtensionHelper<'_, '_, '_> { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, @@ -241,7 +194,7 @@ impl Serialize for ExtensionHelper<'_, '_> { ser.serialize_field( "prf", &PrfInputs { - eval: self.extension.prf, + eval: self.extension.prf.map(|prf| prf.0), eval_by_credential: PrfCreds(self.allow_credentials), }, ) @@ -252,18 +205,18 @@ impl Serialize for ExtensionHelper<'_, '_> { } /// Helper type that peforms the serialization for both [`DiscoverableAuthenticationClientState`] and /// [`NonDiscoverableAuthenticationClientState`] and -struct AuthenticationClientState<'rp_id, 'prf, 'opt, 'cred>( - &'opt PublicKeyCredentialRequestOptions<'rp_id, 'prf>, +struct AuthenticationClientState<'rp_id, 'prf_first, 'prf_second, 'opt, 'cred>( + &'opt PublicKeyCredentialRequestOptions<'rp_id, 'prf_first, 'prf_second>, &'cred AllowedCredentials, ); -impl Serialize for AuthenticationClientState<'_, '_, '_, '_> { +impl Serialize for AuthenticationClientState<'_, '_, '_, '_, '_> { #[inline] fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { serializer - .serialize_struct("AuthenticationClientState", 9) + .serialize_struct("PublicKeyCredentialRequestOptions", 7) .and_then(|mut ser| { ser.serialize_field("challenge", &self.0.challenge) .and_then(|()| { @@ -299,9 +252,69 @@ impl Serialize for AuthenticationClientState<'_, '_, '_, '_> { }) } } -impl Serialize for DiscoverableAuthenticationClientState<'_, '_> { +/// `"mediation"`. +const MEDIATION: &str = "mediation"; +/// `"publicKey"`. +const PUBLIC_KEY: &str = "publicKey"; +impl Serialize for DiscoverableCredentialRequestOptions<'_, '_, '_> { + /// Serializes `self` to conform with + /// [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions). + /// + /// Note [`signal`](https://www.w3.org/TR/credential-management-1/#dom-credentialrequestoptions-signal) + /// is not present, and [`publicKey`](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry) + /// is serialized to conform to + /// [`PublicKeyCredentialRequestOptionsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptionsjson). + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer + .serialize_struct("DiscoverableCredentialRequestOptions", 2) + .and_then(|mut ser| { + ser.serialize_field(MEDIATION, &self.mediation) + .and_then(|()| { + ser.serialize_field( + PUBLIC_KEY, + &AuthenticationClientState( + &self.public_key, + &AllowedCredentials::with_capacity(0), + ), + ) + .and_then(|()| ser.end()) + }) + }) + } +} +impl Serialize for NonDiscoverableCredentialRequestOptions<'_, '_, '_> { /// Serializes `self` to conform with + /// [`CredentialRequestOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialrequestoptions). + /// + /// Note [`signal`](https://www.w3.org/TR/credential-management-1/#dom-credentialrequestoptions-signal) + /// is not present, and [`publicKey`](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry) + /// is serialized to conform to /// [`PublicKeyCredentialRequestOptionsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptionsjson). + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer + .serialize_struct("NonDiscoverableCredentialRequestOptions", 2) + .and_then(|mut ser| { + ser.serialize_field(MEDIATION, &self.mediation) + .and_then(|()| { + ser.serialize_field( + PUBLIC_KEY, + &AuthenticationClientState(&self.options, &self.allow_credentials), + ) + .and_then(|()| ser.end()) + }) + }) + } +} +impl Serialize for DiscoverableAuthenticationClientState<'_, '_, '_> { + /// Serializes `self` according to [`DiscoverableCredentialRequestOptions::serialize`]. /// /// # Examples /// @@ -310,46 +323,48 @@ impl Serialize for DiscoverableAuthenticationClientState<'_, '_> { /// # request::{ /// # auth::{ /// # AllowedCredential, AllowedCredentials, CredentialSpecificExtension, Extension, - /// # PrfInput, PrfInputOwned, DiscoverableCredentialRequestOptions + /// # PrfInputOwned, DiscoverableCredentialRequestOptions /// # }, - /// # AsciiDomain, ExtensionReq, Hint, RpId, PublicKeyCredentialDescriptor, Credentials, UserVerificationRequirement, + /// # AsciiDomain, ExtensionReq, Hint, PrfInput, RpId, PublicKeyCredentialDescriptor, Credentials, UserVerificationRequirement, /// # }, /// # response::{AuthTransports, CredentialId}, /// # }; /// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// let mut options = DiscoverableCredentialRequestOptions::passkey(&rp_id); - /// options.0.hints = Hint::SecurityKey; - /// options.0.extensions = Extension { - /// prf: Some(PrfInput { + /// options.public_key.hints = Hint::SecurityKey; + /// options.public_key.extensions = Extension { + /// prf: Some((PrfInput { /// first: [0; 4].as_slice(), /// second: None, - /// ext_info: ExtensionReq::Require, - /// }), + /// }, ExtensionReq::Require)), /// }; /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap(); /// let json = serde_json::json!({ - /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", - /// "timeout":300000, - /// "rpId":"example.com", - /// "allowCredentials":[], - /// "userVerification":"required", - /// "hints":[ - /// "security-key" - /// ], - /// "extensions":{ - /// "prf":{ - /// "eval":{ - /// "first":"AAAAAA" - /// }, + /// "mediation":"optional", + /// "publicKey":{ + /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", + /// "timeout":300000, + /// "rpId":"example.com", + /// "allowCredentials":[], + /// "userVerification":"required", + /// "hints":[ + /// "security-key" + /// ], + /// "extensions":{ + /// "prf":{ + /// "eval":{ + /// "first":"AAAAAA" + /// }, + /// } /// } /// } /// }).to_string(); /// // Since `Challenge`s are randomly generated, we don't know what it will be; thus /// // we test the JSON string for everything except it. /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(..14), json.get(..14)); + /// assert_eq!(client_state.get(..50), json.get(..50)); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(36..), json.get(36..)); + /// assert_eq!(client_state.get(72..), json.get(72..)); /// # Ok::<_, webauthn_rp::AggErr>(()) /// ``` #[inline] @@ -357,13 +372,11 @@ impl Serialize for DiscoverableAuthenticationClientState<'_, '_> { where S: Serializer, { - AuthenticationClientState(&self.0.0, &AllowedCredentials::with_capacity(0)) - .serialize(serializer) + self.0.serialize(serializer) } } -impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { - /// Serializes `self` to conform with - /// [`PublicKeyCredentialRequestOptionsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialrequestoptionsjson). +impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_, '_> { + /// Serializes `self` according to [`NonDiscoverableCredentialRequestOptions::serialize`]. /// /// # Examples /// @@ -374,9 +387,9 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { /// # request::{ /// # auth::{ /// # AllowedCredential, AllowedCredentials, CredentialSpecificExtension, Extension, - /// # PrfInput, PrfInputOwned, NonDiscoverableCredentialRequestOptions + /// # PrfInputOwned, NonDiscoverableCredentialRequestOptions /// # }, - /// # AsciiDomain, ExtensionReq, Hint, RpId, PublicKeyCredentialDescriptor, Credentials, UserVerificationRequirement, + /// # AsciiDomain, ExtensionReq, Hint, PrfInput, RpId, PublicKeyCredentialDescriptor, Credentials, UserVerificationRequirement, /// # }, /// # response::{AuthTransports, CredentialId}, /// # }; @@ -402,7 +415,7 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { /// prf: Some(PrfInputOwned { /// first: vec![2; 6], /// second: Some(vec![3; 2]), - /// ext_info: ExtensionReq::Require, + /// ext_req: ExtensionReq::Require, /// }), /// }, /// }); @@ -412,18 +425,17 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { /// # #[cfg(all(feature = "bin", feature = "custom"))] /// let opts = options.options(); /// # #[cfg(not(all(feature = "bin", feature = "custom")))] - /// # let mut opts = webauthn_rp::DiscoverableCredentialRequestOptions::passkey(&rp_id).0; + /// # let mut opts = webauthn_rp::DiscoverableCredentialRequestOptions::passkey(&rp_id).public_key; /// opts.hints = Hint::SecurityKey; /// // This is actually useless since `CredentialSpecificExtension` takes priority /// // when the client receives the payload. We set it for illustration purposes only. /// // If `creds` contained an `AllowedCredential` that didn't set /// // `CredentialSpecificExtension::prf`, then this would be used for it. /// opts.extensions = Extension { - /// prf: Some(PrfInput { + /// prf: Some((PrfInput { /// first: [0; 4].as_slice(), /// second: None, - /// ext_info: ExtensionReq::Require, - /// }), + /// }, ExtensionReq::Require)), /// }; /// // Since we are requesting the PRF extension, we must require user verification; otherwise /// // `NonDiscoverableCredentialRequestOptions::start_ceremony` would error. @@ -431,29 +443,32 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { /// # #[cfg(all(feature = "bin", feature = "custom"))] /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap(); /// let json = serde_json::json!({ - /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", - /// "timeout":300000, - /// "rpId":"example.com", - /// "allowCredentials":[ - /// { - /// "type":"public-key", - /// "id":"AAAAAAAAAAAAAAAAAAAAAA", - /// "transports":["usb"] - /// } - /// ], - /// "userVerification":"required", - /// "hints":[ - /// "security-key" - /// ], - /// "extensions":{ - /// "prf":{ - /// "eval":{ - /// "first":"AAAAAA" - /// }, - /// "evalByCredential":{ - /// "AAAAAAAAAAAAAAAAAAAAAA":{ - /// "first":"AgICAgIC", - /// "second":"AwM" + /// "mediation":"optional", + /// "publicKey":{ + /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", + /// "timeout":300000, + /// "rpId":"example.com", + /// "allowCredentials":[ + /// { + /// "type":"public-key", + /// "id":"AAAAAAAAAAAAAAAAAAAAAA", + /// "transports":["usb"] + /// } + /// ], + /// "userVerification":"required", + /// "hints":[ + /// "security-key" + /// ], + /// "extensions":{ + /// "prf":{ + /// "eval":{ + /// "first":"AAAAAA" + /// }, + /// "evalByCredential":{ + /// "AAAAAAAAAAAAAAAAAAAAAA":{ + /// "first":"AgICAgIC", + /// "second":"AwM" + /// } /// } /// } /// } @@ -462,9 +477,9 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { /// // Since `Challenge`s are randomly generated, we don't know what it will be; thus /// // we test the JSON string for everything except it. /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(..14), json.get(..14)); + /// assert_eq!(client_state.get(..50), json.get(..50)); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(36..), json.get(36..)); + /// assert_eq!(client_state.get(72..), json.get(72..)); /// # Ok::<_, webauthn_rp::AggErr>(()) /// ``` #[inline] @@ -472,6 +487,6 @@ impl Serialize for NonDiscoverableAuthenticationClientState<'_, '_> { where S: Serializer, { - AuthenticationClientState(&self.0.options, &self.0.allow_credentials).serialize(serializer) + self.0.serialize(serializer) } } diff --git a/src/request/auth/ser_server_state.rs b/src/request/auth/ser_server_state.rs @@ -19,24 +19,25 @@ use std::time::{SystemTime, SystemTimeError}; impl EncodeBuffer for ServerPrfInfo { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { match *self { + Self::None => 0u8.encode_into_buffer(buffer), Self::One(req) => { - 0u8.encode_into_buffer(buffer); - req + 1u8.encode_into_buffer(buffer); + req.encode_into_buffer(buffer); } Self::Two(req) => { - 1u8.encode_into_buffer(buffer); - req + 2u8.encode_into_buffer(buffer); + req.encode_into_buffer(buffer); } } - .encode_into_buffer(buffer); } } impl<'a> DecodeBuffer<'a> for ServerPrfInfo { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { u8::decode_from_buffer(data).and_then(|val| match val { - 0 => ExtensionReq::decode_from_buffer(data).map(Self::One), - 1 => ExtensionReq::decode_from_buffer(data).map(Self::Two), + 0 => Ok(Self::None), + 1 => ExtensionReq::decode_from_buffer(data).map(Self::One), + 2 => ExtensionReq::decode_from_buffer(data).map(Self::Two), _ => Err(EncDecErr), }) } @@ -49,7 +50,7 @@ impl EncodeBuffer for ServerCredSpecificExtensionInfo { impl<'a> DecodeBuffer<'a> for ServerCredSpecificExtensionInfo { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { - Option::decode_from_buffer(data).map(|prf| Self { prf }) + ServerPrfInfo::decode_from_buffer(data).map(|prf| Self { prf }) } } impl EncodeBuffer for CredInfo { @@ -74,7 +75,7 @@ impl EncodeBuffer for ServerExtensionInfo { impl<'a> DecodeBuffer<'a> for ServerExtensionInfo { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { - Option::decode_from_buffer(data).map(|prf| Self { prf }) + ServerPrfInfo::decode_from_buffer(data).map(|prf| Self { prf }) } } impl EncodeBuffer for SignatureCounterEnforcement { diff --git a/src/request/register.rs b/src/request/register.rs @@ -6,15 +6,15 @@ use super::{ AuthenticatorAttachment, register::{ Attestation, AttestationFormat, AuthenticatorExtensionOutput, - ClientExtensionsOutputs, CredentialProtectionPolicy, Registration, + ClientExtensionsOutputs, CredentialProtectionPolicy, HmacSecret, Registration, UncompressedPubKey, error::{ExtensionErr, RegCeremonyErr}, }, }, }, BackupReq, Ceremony, Challenge, CredentialMediationRequirement, ExtensionInfo, ExtensionReq, - Hint, Origin, PublicKeyCredentialDescriptor, RpId, SentChallenge, THREE_HUNDRED_THOUSAND, - TimedCeremony, UserVerificationRequirement, + Hint, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, SentChallenge, + THREE_HUNDRED_THOUSAND, TimedCeremony, UserVerificationRequirement, register::error::{CreationOptionsErr, NicknameErr, UsernameErr}, }; #[cfg(doc)] @@ -694,7 +694,7 @@ impl Default for FourToSixtyThree { } /// 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 { +pub struct Extension<'prf_first, 'prf_second> { /// [`credProps`](https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension). /// /// The best one can do to ensure a server-side credential is created is by sending @@ -729,217 +729,25 @@ pub struct Extension { /// 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`]. - /// - /// 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 { - /// Validates the extensions. - fn validate( - self, - client_ext: ClientExtensionsOutputs, - auth_ext: AuthenticatorExtensionOutput, - error_unsolicited: bool, - ) -> Result<(), ExtensionErr> { - if error_unsolicited { - self.validate_unsolicited(client_ext, auth_ext) - } else { - Ok(()) - } - .and_then(|()| { - self.validate_required(client_ext, auth_ext) - .and_then(|()| self.validate_value(client_ext, auth_ext)) - }) - } - /// Validates if there are any unsolicited extensions. - /// - /// Note no distinction is made between an extension that is empty and one that is not (i.e., we are checking - /// purely for the existence of extension keys). - fn validate_unsolicited( - mut self, - client_ext: ClientExtensionsOutputs, - auth_ext: AuthenticatorExtensionOutput, - ) -> Result<(), ExtensionErr> { - // For simpler code, we artificially set non-requested extensions after verifying there was not an error - // and recursively call this function. There are so few extensions and the checks are fast that there - // should be no worry of stack overflow or performance overhead. - if self.cred_props.is_some() { - if !matches!(self.cred_protect, CredProtect::None) { - if self.min_pin_length.is_some() { - // This is the last extension, so recursion stops here. - if self.prf.is_some() { - Ok(()) - } else if client_ext.prf.is_some() { - Err(ExtensionErr::ForbiddenPrf) - } else if auth_ext.hmac_secret.is_some() { - Err(ExtensionErr::ForbiddenHmacSecret) - } else { - Ok(()) - } - } else if auth_ext.min_pin_length.is_some() { - Err(ExtensionErr::ForbiddenMinPinLength) - } else { - // Pretend to set `minPinLength`, so we can check `prf`. - 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( - false, - ExtensionInfo::RequireEnforceValue, - ); - self.validate_unsolicited(client_ext, auth_ext) - } - } else if client_ext.cred_props.is_some() { - Err(ExtensionErr::ForbiddenCredProps) - } else { - // Pretend to set `credProps`; so we can check `credProtect`, `minPinLength`, and `prf` extensions. - self.cred_props = Some(ExtensionReq::Require); - self.validate_unsolicited(client_ext, auth_ext) - } - } - /// Validates if any required extensions don't have a corresponding response. - /// - /// Note empty extensions are treated as missing. For example when requiring the `credProps` extension, - /// all of the following responses would lead to a failure: - /// `{"clientExtensionResults":{}}`: no extensions. - /// `{"clientExtensionResults":{"prf":true}}`: only the `prf` extension. - /// `{"clientExtensionResults":{"credProps":{}}}`: empty `credProps` extension. - /// `{"clientExtensionResults":{"credProps":{"foo":false}}}`: `credProps` extension doesn't contain at least one - /// expected field (i.e., still "empty"). - fn validate_required( - self, - client_ext: ClientExtensionsOutputs, - auth_ext: AuthenticatorExtensionOutput, - ) -> Result<(), ExtensionErr> { - // We don't check `self.cred_protect` since `CredProtect::validate` checks for both a required response - // and value enforcement; thus it only needs to be checked once (which it is in `Self::validate_value`). - self.cred_props - .map_or(Ok(()), |info| { - if matches!(info, ExtensionReq::Require) { - if client_ext - .cred_props - .is_some_and(|props| props.rk.is_some()) - { - Ok(()) - } else { - Err(ExtensionErr::MissingCredProps) - } - } else { - Ok(()) - } - }) - .and_then(|()| { - self.min_pin_length - .map_or(Ok(()), |info| { - if matches!( - info.1, - ExtensionInfo::RequireEnforceValue - | ExtensionInfo::RequireDontEnforceValue - ) { - auth_ext - .min_pin_length - .ok_or(ExtensionErr::MissingMinPinLength) - .map(|_| ()) - } else { - Ok(()) - } - }) - .and_then(|()| { - self.prf.map_or(Ok(()), |info| { - if matches!( - info, - ExtensionInfo::RequireEnforceValue - | ExtensionInfo::RequireDontEnforceValue - ) { - if client_ext.prf.is_some() { - Ok(()) - } else { - Err(ExtensionErr::MissingPrf) - } - } else { - Ok(()) - } - }) - }) - }) - } - /// Validates the value of any extensions sent from the client. - /// - /// Note missing and empty extensions are always OK. - fn validate_value( - self, - client_ext: ClientExtensionsOutputs, - auth_ext: AuthenticatorExtensionOutput, - ) -> Result<(), ExtensionErr> { - // This also checks for a missing response. Instead of duplicating that check, we only call - // `self.cred_protect.validate` once here and not also in `Self::validate_required`. - self.cred_protect - .validate(auth_ext.cred_protect) - .and_then(|()| { - self.min_pin_length - .map_or(Ok(()), |info| { - if matches!( - info.1, - ExtensionInfo::RequireEnforceValue | ExtensionInfo::AllowEnforceValue - ) { - auth_ext.min_pin_length.map_or(Ok(()), |pin| { - if pin >= info.0 { - Ok(()) - } else { - Err(ExtensionErr::InvalidMinPinLength(info.0, pin)) - } - }) - } else { - Ok(()) - } - }) - .and_then(|()| { - self.prf.map_or(Ok(()), |info| { - if matches!( - info, - ExtensionInfo::RequireEnforceValue - | ExtensionInfo::AllowEnforceValue - ) { - client_ext - .prf - .map_or(Ok(()), |prf| { - if prf.enabled { - Ok(()) - } else { - Err(ExtensionErr::InvalidPrfValue) - } - }) - .and_then(|()| { - auth_ext.hmac_secret.map_or(Ok(()), |hmac| { - if hmac { - Ok(()) - } else { - Err(ExtensionErr::InvalidHmacSecretValue) - } - }) - }) - } else { - Ok(()) - } - }) - }) - }) - } + /// Note some authenticators can only enable `prf` during registration (e.g., CTAP authenticators that only + /// support + /// [`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) + /// and not + /// [`hmac-secret-mc`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-make-cred-extension)); + /// thus the value of `PrfInput` is ignored and only used as a signal to enable `prf`. For many such + /// authenticators, not using this extension during registration will not preclude them from being used during + /// authentication; however it is still encouraged to use the extension during registration since some + /// authenticators actually require it. + /// + /// When the underlying credential is expected to be used during discoverable requests, it is likely that + /// `'prf_first` will be `'static` and [`PrfInput::second`] is `None` since one will not be able to + /// realistically rotate the underlying inputs and further the same input will likely be used for all credentials. + /// For credentials intended to be used during non-discoverable requests, however, one is encouraged to rotate + /// the inputs and have unique values for each credential. + pub prf: Option<(PrfInput<'prf_first, 'prf_second>, ExtensionInfo)>, } #[cfg(test)] -impl PartialEq for Extension { +impl PartialEq for Extension<'_, '_> { fn eq(&self, other: &Self) -> bool { self.cred_props == other.cred_props && self.cred_protect == other.cred_protect @@ -1463,18 +1271,18 @@ impl PartialEq for AuthenticatorSelectionCriteria { && self.user_verification == other.user_verification } } -/// Helper that verifies the overlap of [`PublicKeyCredentialCreationOptions::start_ceremony`] and +/// Helper that verifies the overlap of [`CredentialCreationOptions::start_ceremony`] and /// [`RegistrationServerState::decode`]. const fn validate_options_helper( auth_crit: AuthenticatorSelectionCriteria, - extensions: Extension, + extensions: ServerExtensionInfo, ) -> Result<(), CreationOptionsErr> { if matches!( auth_crit.user_verification, UserVerificationRequirement::Required ) { Ok(()) - } else if extensions.prf.is_some() { + } else if !matches!(extensions.prf, ServerPrfInfo::None) { Err(CreationOptionsErr::PrfWithoutUserVerification) } else if matches!( extensions.cred_protect, @@ -1485,22 +1293,263 @@ const fn validate_options_helper( Ok(()) } } -/// The [`PublicKeyCredentialCreationOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions) +/// The [`CredentialCreationOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialcreationoptions) /// to send to the client when registering a new credential. /// /// Upon saving the [`RegistrationServerState`] returned from [`Self::start_ceremony`], one MUST send /// [`RegistrationClientState`] to the client ASAP. After receiving the newly created [`Registration`], it is /// validated using [`RegistrationServerState::verify`]. #[derive(Debug)] -pub struct PublicKeyCredentialCreationOptions< +pub struct CredentialCreationOptions< 'rp_id, 'user_name, 'user_display_name, 'user_id, + 'prf_first, + 'prf_second, const USER_LEN: usize, > { /// [`mediation`](https://www.w3.org/TR/credential-management-1/#dom-credentialcreationoptions-mediation). pub mediation: CredentialMediationRequirement, + /// `public-key` [credential type](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry). + pub public_key: PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + >, +} +impl< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + const USER_LEN: usize, +> + CredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + > +{ + /// Sets [`Self::mediation`] to [`CredentialMediationRequirement::default`] and + /// [`Self::public_key`] to [`PublicKeyCredentialCreationOptions::passkey`]. + #[inline] + #[must_use] + pub fn passkey<'a: 'rp_id, 'b: 'user_name, 'c: 'user_display_name, 'd: 'user_id>( + rp_id: &'a RpId, + user: PublicKeyCredentialUserEntity<'b, 'c, 'd, USER_LEN>, + exclude_credentials: Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, + ) -> Self { + Self { + mediation: CredentialMediationRequirement::default(), + public_key: PublicKeyCredentialCreationOptions::passkey( + rp_id, + user, + exclude_credentials, + ), + } + } + /// Convenience function for [`Self::passkey`] passing an empty `Vec`. + /// + /// This MUST only be used when this is the first credential for a user. + #[inline] + #[must_use] + pub fn first_passkey<'a: 'rp_id, 'b: 'user_name, 'c: 'user_display_name, 'd: 'user_id>( + rp_id: &'a RpId, + user: PublicKeyCredentialUserEntity<'b, 'c, 'd, USER_LEN>, + ) -> Self { + Self::passkey(rp_id, user, Vec::new()) + } + /// Convenience function for [`Self::first_passkey`] passing [`PublicKeyCredentialUserEntity::from`] applied + /// to `user_id` for `user`. + /// + /// This MUST only be used when user information is provided _after_ registration (e.g., when the client + /// sends user name and user display name along with [`Registration`]). + /// + /// Because user information is likely known for existing accounts, this will often only be called during + /// greenfield deployments. + #[inline] + #[must_use] + pub fn first_passkey_with_blank_user_info<'a: 'rp_id, 'b: 'user_id>( + rp_id: &'a RpId, + user_id: &'b UserHandle<USER_LEN>, + ) -> Self { + Self::first_passkey(rp_id, user_id.into()) + } + /// Sets [`Self::mediation`] to [`CredentialMediationRequirement::default`] and + /// [`Self::public_key`] to [`PublicKeyCredentialCreationOptions::second_factor`]. + #[inline] + #[must_use] + pub fn second_factor<'a: 'rp_id, 'b: 'user_name, 'c: 'user_display_name, 'd: 'user_id>( + rp_id: &'a RpId, + user: PublicKeyCredentialUserEntity<'b, 'c, 'd, USER_LEN>, + exclude_credentials: Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, + ) -> Self { + let mut opts = Self::passkey(rp_id, user, exclude_credentials); + opts.public_key.authenticator_selection = AuthenticatorSelectionCriteria::second_factor(); + opts.public_key.extensions.cred_props = Some(ExtensionReq::Allow); + opts.public_key.extensions.cred_protect = + CredProtect::UserVerificationOptionalWithCredentialIdList( + false, + ExtensionInfo::AllowEnforceValue, + ); + opts + } + /// Convenience function for [`Self::second_factor`] passing an empty `Vec`. + /// + /// This MUST only be used when this is the first credential for a user. + #[inline] + #[must_use] + pub fn first_second_factor<'a: 'rp_id, 'b: 'user_name, 'c: 'user_display_name, 'd: 'user_id>( + rp_id: &'a RpId, + user: PublicKeyCredentialUserEntity<'b, 'c, 'd, USER_LEN>, + ) -> Self { + Self::second_factor(rp_id, user, Vec::new()) + } + /// Begins the [registration ceremony](https://www.w3.org/TR/webauthn-3/#registration-ceremony) consuming + /// `self`. Note that the expiration [`Instant`]/[`SystemTime`] is saved, so `RegistrationClientState` MUST be + /// sent ASAP. In order to complete registration, the returned `RegistrationServerState` MUST be saved so that + /// it can later be used to verify the new credential with [`RegistrationServerState::verify`]. + /// + /// # Errors + /// + /// Errors iff `self` contains incompatible configuration. + /// + /// # Examples + /// + /// ``` + /// # #[cfg(not(feature = "serializable_server_state"))] + /// # use std::time::Instant; + /// # #[cfg(not(feature = "serializable_server_state"))] + /// # use webauthn_rp::request::TimedCeremony as _; + /// # use webauthn_rp::request::{ + /// # register::{CredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle64}, + /// # AsciiDomain, RpId + /// # }; + /// # #[cfg(not(feature = "serializable_server_state"))] + /// assert!( + /// CredentialCreationOptions::passkey( + /// &RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?), + /// PublicKeyCredentialUserEntity { + /// name: "bernard.riemann".try_into()?, + /// id: &UserHandle64::new(), + /// display_name: Some("Georg Friedrich Bernhard Riemann".try_into()?) + /// }, + /// Vec::new() + /// ).start_ceremony()?.0.expiration() > Instant::now() + /// ); + /// # Ok::<_, webauthn_rp::AggErr>(()) + /// ``` + #[inline] + pub fn start_ceremony( + mut self, + ) -> Result< + ( + RegistrationServerState<USER_LEN>, + RegistrationClientState< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + >, + ), + CreationOptionsErr, + > { + let extensions = self.public_key.extensions.into(); + validate_options_helper(self.public_key.authenticator_selection, extensions).and_then( + |()| { + #[cfg(not(feature = "serializable_server_state"))] + let now = Instant::now(); + #[cfg(feature = "serializable_server_state")] + let now = SystemTime::now(); + now.checked_add(Duration::from_millis( + NonZeroU64::from(self.public_key.timeout).get(), + )) + .ok_or(CreationOptionsErr::InvalidTimeout) + .map(|expiration| { + // We remove duplicates. The order has no significance, so this is OK. + self.public_key + .exclude_credentials + .sort_unstable_by(|a, b| a.id.as_ref().cmp(b.id.as_ref())); + self.public_key + .exclude_credentials + .dedup_by(|a, b| a.id.as_ref() == b.id.as_ref()); + ( + RegistrationServerState { + mediation: self.mediation, + challenge: SentChallenge(self.public_key.challenge.0), + pub_key_cred_params: self.public_key.pub_key_cred_params, + authenticator_selection: self.public_key.authenticator_selection, + extensions, + expiration, + user_id: *self.public_key.user.id, + }, + RegistrationClientState(self), + ) + }) + }, + ) + } +} +/// `CredentialCreationOptions` based on a [`UserHandle64`]. +pub type CredentialCreationOptions64< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, +> = CredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_HANDLE_MAX_LEN, +>; +/// `CredentialCreationOptions` based on a [`UserHandle16`]. +pub type CredentialCreationOptions16< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, +> = CredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + 16, +>; +/// The [`PublicKeyCredentialCreationOptions`](https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialcreationoptions) +/// to send to the client when registering a new credential. +#[derive(Debug)] +pub struct PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + const USER_LEN: usize, +> { /// [`rp`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-rp). pub rp_id: &'rp_id RpId, /// [`user`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-user). @@ -1520,10 +1569,18 @@ pub struct PublicKeyCredentialCreationOptions< /// [`authenticatorSelection`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-authenticatorselection). pub authenticator_selection: AuthenticatorSelectionCriteria, /// [`extensions`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions). - pub extensions: Extension, + pub extensions: Extension<'prf_first, 'prf_second>, } impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> - PublicKeyCredentialCreationOptions<'rp_id, 'user_name, 'user_display_name, 'user_id, USER_LEN> + PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + '_, + '_, + USER_LEN, + > { /// Most deployments of passkeys should use this function. Specifically deployments that are both userless and /// passwordless and desire multi-factor authentication (MFA) to be done entirely on the authenticator. It @@ -1534,8 +1591,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`] with `false` and - /// [`ExtensionInfo::AllowEnforceValue`] is used. [`Self::mediation`] is - /// [`CredentialMediationRequirement::Optional`]. + /// [`ExtensionInfo::AllowEnforceValue`] is used. /// /// # Examples /// @@ -1568,7 +1624,6 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> exclude_credentials: Vec<PublicKeyCredentialDescriptor<Vec<u8>>>, ) -> Self { Self { - mediation: CredentialMediationRequirement::Optional, rp_id, user, challenge: Challenge::new(), @@ -1624,7 +1679,7 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> /// credential without requiring user verification. A five-minute timeout is set. [`Extension::cred_props`] /// is [`ExtensionReq::Allow`]. [`Extension::cred_protect`] is /// [`CredProtect::UserVerificationOptionalWithCredentialIdList`] with `false` and - /// [`ExtensionInfo::AllowEnforceValue`]. [`Self::mediation`] is [`CredentialMediationRequirement::Optional`]. + /// [`ExtensionInfo::AllowEnforceValue`]. /// /// 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)). @@ -1686,92 +1741,42 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> ) -> Self { Self::second_factor(rp_id, user, Vec::new()) } - /// Begins the [registration ceremony](https://www.w3.org/TR/webauthn-3/#registration-ceremony) consuming - /// `self`. Note that the expiration [`Instant`]/[`SystemTime`] is saved, so `RegistrationClientState` MUST be - /// sent ASAP. In order to complete registration, the returned `RegistrationServerState` MUST be saved so that - /// it can later be used to verify the new credential with [`RegistrationServerState::verify`]. - /// - /// # Errors - /// - /// Errors iff `self` contains incompatible configuration. - /// - /// # Examples - /// - /// ``` - /// # #[cfg(not(feature = "serializable_server_state"))] - /// # use std::time::Instant; - /// # #[cfg(not(feature = "serializable_server_state"))] - /// # use webauthn_rp::request::TimedCeremony as _; - /// # use webauthn_rp::request::{ - /// # register::{PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle64}, - /// # AsciiDomain, RpId - /// # }; - /// # #[cfg(not(feature = "serializable_server_state"))] - /// assert!( - /// PublicKeyCredentialCreationOptions::passkey( - /// &RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?), - /// PublicKeyCredentialUserEntity { - /// name: "bernard.riemann".try_into()?, - /// id: &UserHandle64::new(), - /// display_name: Some("Georg Friedrich Bernhard Riemann".try_into()?) - /// }, - /// Vec::new() - /// ).start_ceremony()?.0.expiration() > Instant::now() - /// ); - /// # Ok::<_, webauthn_rp::AggErr>(()) - /// ``` - #[inline] - pub fn start_ceremony( - mut self, - ) -> Result< - ( - RegistrationServerState<USER_LEN>, - RegistrationClientState<'rp_id, 'user_name, 'user_display_name, 'user_id, USER_LEN>, - ), - CreationOptionsErr, - > { - validate_options_helper(self.authenticator_selection, self.extensions).and_then(|()| { - #[cfg(not(feature = "serializable_server_state"))] - let now = Instant::now(); - #[cfg(feature = "serializable_server_state")] - let now = SystemTime::now(); - now.checked_add(Duration::from_millis(NonZeroU64::from(self.timeout).get())) - .ok_or(CreationOptionsErr::InvalidTimeout) - .map(|expiration| { - // We remove duplicates. The order has no significance, so this is OK. - self.exclude_credentials - .sort_unstable_by(|a, b| a.id.as_ref().cmp(b.id.as_ref())); - self.exclude_credentials - .dedup_by(|a, b| a.id.as_ref() == b.id.as_ref()); - ( - RegistrationServerState { - mediation: self.mediation, - challenge: SentChallenge(self.challenge.0), - pub_key_cred_params: self.pub_key_cred_params, - authenticator_selection: self.authenticator_selection, - extensions: self.extensions, - expiration, - user_id: *self.user.id, - }, - RegistrationClientState(self), - ) - }) - }) - } } /// `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, - >; +pub type PublicKeyCredentialCreationOptions64< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, +> = PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + 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. +pub type PublicKeyCredentialCreationOptions16< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, +> = PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + 16, +>; +/// Container of a [`CredentialCreationOptions`] that has been used to start the registration ceremony. /// This gets sent to the client ASAP. #[derive(Debug)] pub struct RegistrationClientState< @@ -1779,22 +1784,50 @@ pub struct RegistrationClientState< 'user_name, 'user_display_name, 'user_id, + 'prf_first, + 'prf_second, const USER_LEN: usize, ->(PublicKeyCredentialCreationOptions<'rp_id, 'user_name, 'user_display_name, 'user_id, USER_LEN>); -impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> - RegistrationClientState<'rp_id, 'user_name, 'user_display_name, 'user_id, USER_LEN> +>( + CredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + >, +); +impl< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + const USER_LEN: usize, +> + RegistrationClientState< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + > { - /// Returns the `PublicKeyCredentialCreationOptions` that was used to start a registration ceremony. + /// Returns the `CredentialCreationOptions` that was used to start a registration ceremony. /// /// # Examples /// /// ``` /// # use webauthn_rp::request::{register::{ - /// # CoseAlgorithmIdentifiers, PublicKeyCredentialCreationOptions, + /// # CoseAlgorithmIdentifiers, CredentialCreationOptions, /// # PublicKeyCredentialUserEntity, UserHandle64, /// # }, AsciiDomain, RpId}; /// assert_eq!( - /// PublicKeyCredentialCreationOptions::passkey( + /// CredentialCreationOptions::passkey( /// &RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?), /// PublicKeyCredentialUserEntity { /// name: "david.hilbert".try_into()?, @@ -1806,6 +1839,7 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> /// .start_ceremony()? /// .1 /// .options() + /// .public_key /// .rp_id.as_ref(), /// "example.com" /// ); @@ -1815,22 +1849,52 @@ impl<'rp_id, 'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> #[must_use] pub const fn options( &self, - ) -> &PublicKeyCredentialCreationOptions< + ) -> &CredentialCreationOptions< 'rp_id, 'user_name, 'user_display_name, 'user_id, + 'prf_first, + 'prf_second, USER_LEN, > { &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>; +pub type RegistrationClientState64< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, +> = RegistrationClientState< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + 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>; +pub type RegistrationClientState16< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, +> = RegistrationClientState< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + 16, +>; /// Additional verification options to perform in [`RegistrationServerState::verify`]. #[derive(Clone, Copy, Debug)] pub struct RegistrationVerificationOptions<'origins, 'top_origins, O, T> { @@ -1877,13 +1941,270 @@ impl<O, T> Default for RegistrationVerificationOptions<'_, '_, O, T> { } } } +/// `PrfInput` without the actual data sent to reduce memory usage when storing [`RegistrationServerState`] in an +/// in-memory collection. +#[derive(Clone, Copy, Debug)] +enum ServerPrfInfo { + /// No `PrfInput`. + None, + /// `PrfInput::second` was `None`. + One(ExtensionInfo), + /// `PrfInput::second` was `Some`. + Two(ExtensionInfo), +} +#[cfg(test)] +impl PartialEq for ServerPrfInfo { + fn eq(&self, other: &Self) -> bool { + match *self { + Self::None => matches!(*other, Self::None), + Self::One(info) => matches!(*other, Self::One(info2) if info == info2), + Self::Two(info) => matches!(*other, Self::Two(info2) if info == info2), + } + } +} +impl From<Option<(PrfInput<'_, '_>, ExtensionInfo)>> for ServerPrfInfo { + fn from(value: Option<(PrfInput<'_, '_>, ExtensionInfo)>) -> Self { + value.map_or(Self::None, |val| { + val.0 + .second + .map_or_else(|| Self::One(val.1), |_| Self::Two(val.1)) + }) + } +} +/// `Extension` without the actual data sent to reduce memory usage when storing [`AuthenticationServerState`] +/// in an in-memory collection. +#[derive(Clone, Copy, Debug)] +struct ServerExtensionInfo { + /// `Extension::cred_props`. + cred_props: Option<ExtensionReq>, + /// `Extension::cred_protect`. + cred_protect: CredProtect, + /// `Extension::min_pin_length`. + min_pin_length: Option<(FourToSixtyThree, ExtensionInfo)>, + /// `Extension::prf`. + prf: ServerPrfInfo, +} +impl ServerExtensionInfo { + /// Validates the extensions. + fn validate( + self, + client_ext: ClientExtensionsOutputs, + auth_ext: AuthenticatorExtensionOutput, + error_unsolicited: bool, + ) -> Result<(), ExtensionErr> { + if error_unsolicited { + self.validate_unsolicited(client_ext, auth_ext) + } else { + Ok(()) + } + .and_then(|()| { + self.validate_required(client_ext, auth_ext) + .and_then(|()| self.validate_value(client_ext, auth_ext)) + }) + } + /// Validates if there are any unsolicited extensions. + /// + /// Note no distinction is made between an extension that is empty and one that is not (i.e., we are checking + /// purely for the existence of extension keys). + fn validate_unsolicited( + mut self, + client_ext: ClientExtensionsOutputs, + auth_ext: AuthenticatorExtensionOutput, + ) -> Result<(), ExtensionErr> { + // For simpler code, we artificially set non-requested extensions after verifying there was not an error + // and recursively call this function. There are so few extensions and the checks are fast that there + // should be no worry of stack overflow or performance overhead. + if self.cred_props.is_some() { + if !matches!(self.cred_protect, CredProtect::None) { + if self.min_pin_length.is_some() { + // This is the last extension, so recursion stops here. + if !matches!(self.prf, ServerPrfInfo::None) { + Ok(()) + } else if client_ext.prf.is_some() { + Err(ExtensionErr::ForbiddenPrf) + } else if !matches!(auth_ext.hmac_secret, HmacSecret::None) { + Err(ExtensionErr::ForbiddenHmacSecret) + } else { + Ok(()) + } + } else if auth_ext.min_pin_length.is_some() { + Err(ExtensionErr::ForbiddenMinPinLength) + } else { + // Pretend to set `minPinLength`, so we can check `prf`. + 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( + false, + ExtensionInfo::RequireEnforceValue, + ); + self.validate_unsolicited(client_ext, auth_ext) + } + } else if client_ext.cred_props.is_some() { + Err(ExtensionErr::ForbiddenCredProps) + } else { + // Pretend to set `credProps`; so we can check `credProtect`, `minPinLength`, and `prf` extensions. + self.cred_props = Some(ExtensionReq::Require); + self.validate_unsolicited(client_ext, auth_ext) + } + } + /// Validates if any required extensions don't have a corresponding response. + /// + /// Note empty extensions are treated as missing. For example when requiring the `credProps` extension, + /// all of the following responses would lead to a failure: + /// `{"clientExtensionResults":{}}`: no extensions. + /// `{"clientExtensionResults":{"prf":true}}`: only the `prf` extension. + /// `{"clientExtensionResults":{"credProps":{}}}`: empty `credProps` extension. + /// `{"clientExtensionResults":{"credProps":{"foo":false}}}`: `credProps` extension doesn't contain at least one + /// expected field (i.e., still "empty"). + fn validate_required( + self, + client_ext: ClientExtensionsOutputs, + auth_ext: AuthenticatorExtensionOutput, + ) -> Result<(), ExtensionErr> { + // We don't check `self.cred_protect` since `CredProtect::validate` checks for both a required response + // and value enforcement; thus it only needs to be checked once (which it is in `Self::validate_value`). + self.cred_props + .map_or(Ok(()), |info| { + if matches!(info, ExtensionReq::Require) { + if client_ext + .cred_props + .is_some_and(|props| props.rk.is_some()) + { + Ok(()) + } else { + Err(ExtensionErr::MissingCredProps) + } + } else { + Ok(()) + } + }) + .and_then(|()| { + self.min_pin_length + .map_or(Ok(()), |info| { + if matches!( + info.1, + ExtensionInfo::RequireEnforceValue + | ExtensionInfo::RequireDontEnforceValue + ) { + auth_ext + .min_pin_length + .ok_or(ExtensionErr::MissingMinPinLength) + .map(|_| ()) + } else { + Ok(()) + } + }) + .and_then(|()| match self.prf { + ServerPrfInfo::None => Ok(()), + ServerPrfInfo::One(info) | ServerPrfInfo::Two(info) => { + if matches!( + info, + ExtensionInfo::RequireEnforceValue + | ExtensionInfo::RequireDontEnforceValue + ) { + if client_ext.prf.is_some() { + Ok(()) + } else { + Err(ExtensionErr::MissingPrf) + } + } else { + Ok(()) + } + } + }) + }) + } + /// Validates the value of any extensions sent from the client. + /// + /// Note missing and empty extensions are always OK. + fn validate_value( + self, + client_ext: ClientExtensionsOutputs, + auth_ext: AuthenticatorExtensionOutput, + ) -> Result<(), ExtensionErr> { + // This also checks for a missing response. Instead of duplicating that check, we only call + // `self.cred_protect.validate` once here and not also in `Self::validate_required`. + self.cred_protect + .validate(auth_ext.cred_protect) + .and_then(|()| { + self.min_pin_length + .map_or(Ok(()), |info| { + if matches!( + info.1, + ExtensionInfo::RequireEnforceValue | ExtensionInfo::AllowEnforceValue + ) { + auth_ext.min_pin_length.map_or(Ok(()), |pin| { + if pin >= info.0 { + Ok(()) + } else { + Err(ExtensionErr::InvalidMinPinLength(info.0, pin)) + } + }) + } else { + Ok(()) + } + }) + .and_then(|()| match self.prf { + ServerPrfInfo::None => Ok(()), + ServerPrfInfo::One(info) | ServerPrfInfo::Two(info) => { + if matches!( + info, + ExtensionInfo::RequireEnforceValue + | ExtensionInfo::AllowEnforceValue + ) { + client_ext + .prf + .map_or(Ok(()), |prf| { + if prf.enabled { + Ok(()) + } else { + Err(ExtensionErr::InvalidPrfValue) + } + }) + .and({ + if matches!(auth_ext.hmac_secret, HmacSecret::NotEnabled) { + Err(ExtensionErr::InvalidHmacSecretValue) + } else { + Ok(()) + } + }) + } else { + Ok(()) + } + } + }) + }) + } +} +impl From<Extension<'_, '_>> for ServerExtensionInfo { + fn from(value: Extension<'_, '_>) -> Self { + Self { + cred_props: value.cred_props, + cred_protect: value.cred_protect, + min_pin_length: value.min_pin_length, + prf: value.prf.into(), + } + } +} +#[cfg(test)] +impl PartialEq for ServerExtensionInfo { + fn eq(&self, other: &Self) -> bool { + self.prf == other.prf + } +} // This is essentially the `PublicKeyCredentialCreationOptions` used to create it; however to reduce // memory usage, we remove all unnecessary data making an instance of this 48 bytes in size on // `x86_64-unknown-linux-gnu` platforms. /// State needed to be saved when beginning the registration ceremony. /// -/// Saves the necessary information associated with the [`PublicKeyCredentialCreationOptions`] used to create it -/// via [`PublicKeyCredentialCreationOptions::start_ceremony`] so that registration of a new credential can be +/// Saves the necessary information associated with the [`CredentialCreationOptions`] used to create it +/// via [`CredentialCreationOptions::start_ceremony`] so that registration of a new credential can be /// performed with [`Self::verify`]. /// /// `RegistrationServerState` implements [`Borrow`] of [`SentChallenge`]; thus to obtain the correct @@ -1908,7 +2229,7 @@ pub struct RegistrationServerState<const USER_LEN: usize> { /// [`authenticatorSelection`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-authenticatorselection). authenticator_selection: AuthenticatorSelectionCriteria, /// [`extensions`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions). - extensions: Extension, + extensions: ServerExtensionInfo, /// `Instant` the ceremony expires. #[cfg(not(feature = "serializable_server_state"))] expiration: Instant, @@ -2161,7 +2482,7 @@ mod tests { ))] use super::{ super::{super::AggErr, ExtensionInfo}, - Challenge, CredProtect, FourToSixtyThree, PublicKeyCredentialCreationOptions, RpId, + Challenge, CredProtect, CredentialCreationOptions, FourToSixtyThree, PrfInput, RpId, UserHandle, }; #[cfg(all(feature = "custom", feature = "serializable_server_state"))] @@ -2180,7 +2501,7 @@ mod tests { response::register::{ AuthenticationExtensionsPrfOutputs, AuthenticatorAttestation, ClientExtensionsOutputs, CredentialPropertiesOutput, - CredentialProtectionPolicy, + CredentialProtectionPolicy, HmacSecret, }, }, AuthTransports, @@ -2261,7 +2582,7 @@ mod tests { 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( + let mut opts = CredentialCreationOptions::passkey( &rp_id, PublicKeyCredentialUserEntity { name: "foo".try_into()?, @@ -2270,8 +2591,8 @@ mod tests { }, Vec::new(), ); - opts.challenge = Challenge(0); - opts.extensions = Extension { + opts.public_key.challenge = Challenge(0); + opts.public_key.extensions = Extension { cred_props: None, cred_protect: CredProtect::UserVerificationRequired( false, @@ -2282,7 +2603,13 @@ mod tests { .unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::RequireEnforceValue, )), - prf: Some(ExtensionInfo::RequireEnforceValue), + prf: Some(( + PrfInput { + first: [0].as_slice(), + second: None, + }, + ExtensionInfo::RequireEnforceValue, + )), }; let client_data_json = br#"{"type":"webauthn.create","challenge":"AAAAAAAAAAAAAAAAAAAAAA","origin":"https://example.com","crossOrigin":false}"#.to_vec(); // We over-allocate by 32 bytes. See [`AuthenticatorAttestation::new`] for more information. @@ -2605,7 +2932,7 @@ mod tests { user_verified: bool, cred_protect: CredentialProtectionPolicy, prf: Option<bool>, - hmac: Option<bool>, + hmac: HmacSecret, min_pin: Option<FourToSixtyThree>, cred_props: Option<Option<bool>>, } @@ -2674,13 +3001,28 @@ mod tests { 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 { + if matches!(options.hmac, HmacSecret::None) { options.min_pin.map_or(0, |_| 15) + } else { + 14 + options.min_pin.map_or(0, |_| 14) + + match options.hmac { + HmacSecret::None => unreachable!("bug"), + HmacSecret::NotEnabled | HmacSecret::Enabled => 0, + HmacSecret::One => 65, + HmacSecret::Two => 97, + } } } else { - 14 + options.hmac.map_or(0, |_| 13) + options.min_pin.map_or(0, |_| 14) + 14 + if matches!(options.hmac, HmacSecret::None) { + 0 + } else { + 13 + } + options.min_pin.map_or(0, |_| 14) + + match options.hmac { + HmacSecret::None | HmacSecret::NotEnabled | HmacSecret::Enabled => 0, + HmacSecret::One => 65, + HmacSecret::Two => 97, + } }, // RP ID HASH. // This will be overwritten later. @@ -2725,7 +3067,7 @@ mod tests { 0b0000_0000 } | if matches!(options.cred_protect, CredentialProtectionPolicy::None) - && options.hmac.is_none() + && matches!(options.hmac, HmacSecret::None) && options.min_pin.is_none() { 0 @@ -2833,19 +3175,25 @@ mod tests { 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 matches!(options.hmac, HmacSecret::None) { if options.min_pin.is_some() { - attestation_object.push(CBOR_MAP | 2); - } else { - attestation_object.push(CBOR_MAP | 1); + attestation_object.push(CBOR_MAP | 1) } } else if options.min_pin.is_some() { - attestation_object.push(CBOR_MAP | 1); + attestation_object.push( + CBOR_MAP + | 2 + u8::from(matches!(options.hmac, HmacSecret::One | HmacSecret::Two)), + ); + } else { + attestation_object.push( + CBOR_MAP + | 1 + u8::from(matches!(options.hmac, HmacSecret::One | HmacSecret::Two)), + ); } } else { attestation_object.extend_from_slice( [ - CBOR_MAP | 1 + u8::from(options.hmac.is_some()) + u8::from(options.min_pin.is_some()), + CBOR_MAP | 1 + match options.hmac { HmacSecret::None => 0, HmacSecret::NotEnabled | HmacSecret::Enabled => 1, HmacSecret::One | HmacSecret::Two => 2, } + u8::from(options.min_pin.is_some()), // CBOR text of length 11. CBOR_TEXT | 11, b'c', @@ -2863,7 +3211,7 @@ mod tests { ].as_slice() ) } - options.hmac.map(|h| { + if !matches!(options.hmac, HmacSecret::None) { attestation_object.extend_from_slice( [ // CBOR text of length 11. @@ -2879,11 +3227,15 @@ mod tests { b'r', b'e', b't', - if h { CBOR_TRUE } else { CBOR_FALSE }, + if matches!(options.hmac, HmacSecret::NotEnabled) { + CBOR_FALSE + } else { + CBOR_TRUE + }, ] .as_slice(), ); - }); + } options.min_pin.map(|p| { assert!(p.value() <= 23, "bug"); attestation_object.extend_from_slice( @@ -2907,6 +3259,37 @@ mod tests { .as_slice(), ); }); + if matches!(options.hmac, HmacSecret::One | HmacSecret::Two) { + attestation_object.extend_from_slice( + [ + // CBOR text of length 14. + CBOR_TEXT | 14, + b'h', + b'm', + b'a', + b'c', + b'-', + b's', + b'e', + b'c', + b'r', + b'e', + b't', + b'-', + b'm', + b'c', + CBOR_BYTES | 24, + ] + .as_slice(), + ); + if matches!(options.hmac, HmacSecret::One) { + attestation_object.push(48); + attestation_object.extend_from_slice([1; 48].as_slice()); + } else { + attestation_object.push(80); + attestation_object.extend_from_slice([1; 80].as_slice()); + } + } attestation_object } #[cfg(all(feature = "custom", not(any(feature = "bin", feature = "serde"))))] @@ -2940,10 +3323,10 @@ mod tests { 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; + let mut opts = CredentialCreationOptions::first_passkey_with_blank_user_info(&rp_id, &user); + opts.public_key.challenge = Challenge(0); + opts.public_key.authenticator_selection.user_verification = + UserVerificationRequirement::Preferred; match options.request.prf_uv { PrfUvOptions::None(required) => { if required @@ -2952,19 +3335,25 @@ mod tests { CredProtect::UserVerificationRequired(_, _) ) { - opts.authenticator_selection.user_verification = + opts.public_key.authenticator_selection.user_verification = UserVerificationRequirement::Required; } } PrfUvOptions::Prf(info) => { - opts.authenticator_selection.user_verification = + opts.public_key.authenticator_selection.user_verification = UserVerificationRequirement::Required; - opts.extensions.prf = Some(info); + opts.public_key.extensions.prf = Some(( + PrfInput { + first: [0].as_slice(), + second: None, + }, + info, + )); } } - opts.extensions.cred_protect = options.request.protect; - opts.extensions.cred_props = options.request.props; - opts.extensions.min_pin_length = options.request.pin; + opts.public_key.extensions.cred_protect = options.request.protect; + opts.public_key.extensions.cred_props = options.request.props; + opts.public_key.extensions.min_pin_length = options.request.pin; opts.start_ceremony()? .0 .verify(&rp_id, &registration, &reg_opts) @@ -2972,7 +3361,7 @@ mod tests { .map(|_| ()) } /// Test all, and only, possible `UserNotVerified` errors. - /// 4 * 3 * 3 * 2 * 13 * 5 * 3 * 5 * 4 * 4 = 1,123,200 tests. + /// 4 * 3 * 5 * 2 * 13 * 5 * 3 * 5 * 4 * 4 = 1,872,000 tests. /// We ignore this due to how long it takes (around 30 seconds or so). #[test] #[ignore] @@ -2985,7 +3374,13 @@ mod tests { 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_HMAC_OPTIONS: [HmacSecret; 5] = [ + HmacSecret::None, + HmacSecret::NotEnabled, + HmacSecret::Enabled, + HmacSecret::One, + HmacSecret::Two, + ]; const ALL_UNSOLICIT_OPTIONS: [bool; 2] = [false, true]; const ALL_CRED_PROTECT_OPTIONS: [CredProtect; 13] = [ CredProtect::None, @@ -3089,15 +3484,15 @@ mod tests { } } /// Test all, and only, possible `ForbiddenCredProps` errors. - /// 4 * 3 * 3 * 2 * 13 * 6 * 5 * 3 * 4 = 336,960 + /// 4 * 3 * 5 * 2 * 13 * 6 * 5 * 3 * 4 = 561,600 /// - - /// 4 * 3 * 3 * 4 * 6 * 5 * 3 * 4 = 51,840 + /// 4 * 3 * 5 * 4 * 6 * 5 * 3 * 4 = 86,400 /// - - /// 4 * 3 * 3 * 13 * 5 * 5 * 3 * 4 = 140,400 + /// 4 * 3 * 5 * 13 * 5 * 5 * 3 * 4 = 234,000 /// + - /// 4 * 3 * 3 * 4 * 5 * 5 * 3 * 4 = 43,200 + /// 4 * 3 * 5 * 4 * 5 * 5 * 3 * 4 = 72,000 /// = - /// 187,920 total tests. + /// 313,200 total tests. /// We ignore this due to how long it takes (around 6 seconds or so). #[test] #[ignore] @@ -3110,7 +3505,13 @@ mod tests { 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_HMAC_OPTIONS: [HmacSecret; 5] = [ + HmacSecret::None, + HmacSecret::NotEnabled, + HmacSecret::Enabled, + HmacSecret::One, + HmacSecret::Two, + ]; const ALL_UV_OPTIONS: [bool; 2] = [false, true]; const ALL_CRED_PROTECT_OPTIONS: [CredProtect; 13] = [ CredProtect::None, @@ -3230,7 +3631,7 @@ mod tests { }, response: TestResponseOptions { user_verified: true, - hmac: None, + hmac: HmacSecret::None, cred_protect: CredentialProtectionPolicy::None, prf: Some(true), min_pin: None, @@ -3240,14 +3641,14 @@ mod tests { 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.hmac = HmacSecret::NotEnabled; 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.hmac = HmacSecret::Enabled; 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); + opts.response.hmac = HmacSecret::NotEnabled; 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)); @@ -3255,7 +3656,7 @@ mod tests { validate(opts)?; opts.request.prf_uv = PrfUvOptions::None(false); opts.response.user_verified = false; - opts.response.hmac = None; + opts.response.hmac = HmacSecret::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; @@ -3278,7 +3679,7 @@ mod tests { }, response: TestResponseOptions { user_verified: true, - hmac: None, + hmac: HmacSecret::None, cred_protect: CredentialProtectionPolicy::UserVerificationRequired, prf: None, min_pin: None, diff --git a/src/request/register/error.rs b/src/request/register/error.rs @@ -1,6 +1,6 @@ #[cfg(doc)] use super::{ - AuthenticatorSelectionCriteria, CredProtect, Extension, Nickname, + AuthenticatorSelectionCriteria, CredProtect, CredentialCreationOptions, Extension, Nickname, PublicKeyCredentialCreationOptions, USER_HANDLE_MAX_LEN, USER_HANDLE_MIN_LEN, UserHandle, UserVerificationRequirement, Username, }; @@ -50,7 +50,7 @@ impl Display for UsernameErr { } } impl Error for UsernameErr {} -/// Error returned by [`PublicKeyCredentialCreationOptions::start_ceremony`]. +/// Error returned by [`CredentialCreationOptions::start_ceremony`]. #[derive(Clone, Copy, Debug)] pub enum CreationOptionsErr { /// Error when [`Extension::prf`] is [`Some`] but [`AuthenticatorSelectionCriteria::user_verification`] is not diff --git a/src/request/register/ser.rs b/src/request/register/ser.rs @@ -1,9 +1,10 @@ extern crate alloc; use super::{ - AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, - CoseAlgorithmIdentifier, CoseAlgorithmIdentifiers, CredProtect, CrossPlatformHint, Extension, - Hint, Nickname, PlatformHint, PublicKeyCredentialUserEntity, - RegistrationClientState, ResidentKeyRequirement, RpId, UserHandle, Username, + AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, CoseAlgorithmIdentifier, + CoseAlgorithmIdentifiers, CredProtect, CredentialCreationOptions, CrossPlatformHint, Extension, + Hint, Nickname, PlatformHint, PrfInput, PublicKeyCredentialCreationOptions, + PublicKeyCredentialUserEntity, RegistrationClientState, ResidentKeyRequirement, RpId, + UserHandle, Username, }; use alloc::borrow::Cow; #[cfg(doc)] @@ -16,7 +17,7 @@ use core::{ use data_encoding::BASE64URL_NOPAD; use serde::{ de::{Deserialize, Deserializer, Error, Unexpected, Visitor}, - ser::{Serialize, SerializeSeq as _, SerializeStruct, Serializer}, + ser::{Serialize, SerializeSeq as _, SerializeStruct as _, Serializer}, }; impl Serialize for Nickname<'_> { /// Serializes `self` as a [`prim@str`]. @@ -80,12 +81,15 @@ 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()) }) }) @@ -120,7 +124,12 @@ impl Serialize for CoseAlgorithmIdentifiers { { // At most we add `1` four times which clearly cannot overflow or `usize`. serializer - .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)))) + .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) @@ -195,7 +204,10 @@ impl Serialize for UserHandle<1> { where S: Serializer, { - serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str(self.0.as_slice(), [0; crate::base64url_nopad_len(1).unwrap()].as_mut_slice())) + serializer.serialize_str(BASE64URL_NOPAD.encode_mut_str( + self.0.as_slice(), + [0; crate::base64url_nopad_len(1).unwrap()].as_mut_slice(), + )) } } /// Implements [`Serialize`] for [`UserHandle`] of array of length of the passed `usize` literal. @@ -211,7 +223,7 @@ impl Serialize for UserHandle<$x> { 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())) } } @@ -220,9 +232,9 @@ impl Serialize for UserHandle<$x> { } // 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 + 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 @@ -454,20 +466,19 @@ impl Serialize for AuthenticatorSelectionCriteria { } /// Helper that serializes prf registration information to conform with /// [`AuthenticationExtensionsPRFInputs`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfinputs). -/// -/// Since CTAP 2.2 does not allow PRF evaluation at creation time, we send an empty map. -struct Prf; -impl Serialize for Prf { +struct Prf<'a, 'b>(PrfInput<'a, 'b>); +impl Serialize for Prf<'_, '_> { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { - serializer - .serialize_struct("Prf", 0) - .and_then(SerializeStruct::end) + serializer.serialize_struct("Prf", 1).and_then(|mut ser| { + ser.serialize_field("eval", &self.0) + .and_then(|()| ser.end()) + }) } } -impl Serialize for Extension { +impl Serialize for Extension<'_, '_> { /// Serializes `self` to conform with /// [`AuthenticationExtensionsClientInputsJSON`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsclientinputsjson). /// @@ -476,7 +487,7 @@ impl Serialize for Extension { /// ``` /// # use webauthn_rp::request::{ /// # register::{CredProtect, Extension, FourToSixtyThree}, - /// # ExtensionInfo, ExtensionReq, + /// # PrfInput, ExtensionInfo, ExtensionReq, /// # }; /// assert_eq!(serde_json::to_string(&Extension::default())?, r#"{}"#); /// assert_eq!( @@ -484,9 +495,9 @@ impl Serialize for Extension { /// cred_props: Some(ExtensionReq::Allow), /// cred_protect: CredProtect::UserVerificationRequired(false, ExtensionInfo::RequireEnforceValue), /// min_pin_length: Some((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::AllowDontEnforceValue)), - /// prf: Some(ExtensionInfo::AllowEnforceValue) + /// prf: Some((PrfInput { first: [0].as_slice(), second: None, }, ExtensionInfo::AllowEnforceValue)) /// })?, - /// r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{}}"# + /// r#"{"credProps":true,"credentialProtectionPolicy":"userVerificationRequired","enforceCredentialProtectionPolicy":false,"minPinLength":true,"prf":{"eval":{"first":"AA"}}}"# /// ); /// # Ok::<_, serde_json::Error>(()) /// ``` @@ -538,7 +549,8 @@ impl Serialize for Extension { "userVerificationOptional" } CredProtect::UserVerificationOptionalWithCredentialIdList( - enforce, _, + enforce, + _, ) => { enforce_policy = enforce; "userVerificationOptionalWithCredentialIDList" @@ -561,7 +573,9 @@ impl Serialize for Extension { .map_or(Ok(()), |_| ser.serialize_field(MIN_PIN_LENGTH, &true)) .and_then(|()| { self.prf - .map_or(Ok(()), |_| ser.serialize_field(PRF, &Prf)) + .map_or(Ok(()), |(prf, _)| { + ser.serialize_field(PRF, &Prf(prf)) + }) .and_then(|()| ser.end()) }) }) @@ -569,12 +583,156 @@ impl Serialize for Extension { }) } } -impl<'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> Serialize for RegistrationClientState<'_, 'user_name, 'user_display_name, 'user_id, USER_LEN> +impl<'user_name, 'user_display_name, 'user_id, const USER_LEN: usize> Serialize + for PublicKeyCredentialCreationOptions< + '_, + '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). + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + /// "none". + const NONE: &str = "none"; + serializer + .serialize_struct("PublicKeyCredentialCreationOptions", 11) + .and_then(|mut ser| { + ser.serialize_field("rp", &PublicKeyCredentialRpEntity(self.rp_id)) + .and_then(|()| { + ser.serialize_field("user", &self.user).and_then(|()| { + ser.serialize_field("challenge", &self.challenge) + .and_then(|()| { + ser.serialize_field( + "pubKeyCredParams", + &self.pub_key_cred_params, + ) + .and_then(|()| { + ser.serialize_field("timeout", &self.timeout).and_then( + |()| { + ser.serialize_field( + "excludeCredentials", + self.exclude_credentials.as_slice(), + ) + .and_then(|()| { + ser.serialize_field( + "authenticatorSelection", + &self.authenticator_selection, + ) + .and_then(|()| { + ser.serialize_field("hints", &match self.authenticator_selection.authenticator_attachment { + AuthenticatorAttachmentReq::None(hint) => hint, + AuthenticatorAttachmentReq::Platform(hint) => hint.into(), + AuthenticatorAttachmentReq::CrossPlatform(hint) => hint.into(), + }).and_then(|()| { + ser.serialize_field("attestation", NONE).and_then(|()| { + ser.serialize_field("attestationFormats", [NONE].as_slice()).and_then(|()| { + ser.serialize_field("extensions", &self.extensions).and_then(|()| ser.end()) + }) + }) + }) + }) + }) + }, + ) + }) + }) + }) + }) + }) + } +} +impl< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + const USER_LEN: usize, +> Serialize + for CredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + > +where + PublicKeyCredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + >: Serialize, +{ + /// Serializes `self` to conform with + /// [`CredentialCreationOptions`](https://www.w3.org/TR/credential-management-1/#dictdef-credentialcreationoptions). + /// + /// Note [`signal`](https://www.w3.org/TR/credential-management-1/#dom-credentialcreationoptions-signal) + /// is not present, and [`publicKey`](https://www.w3.org/TR/credential-management-1/#sctn-cred-type-registry) + /// is serialized according to [`PublicKeyCredentialCreationOptions::serialize`]. + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer + .serialize_struct("CredentialCreationOptions", 2) + .and_then(|mut ser| { + ser.serialize_field("mediation", &self.mediation) + .and_then(|()| { + ser.serialize_field("publicKey", &self.public_key) + .and_then(|()| ser.end()) + }) + }) + } +} +impl< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + const USER_LEN: usize, +> Serialize + for RegistrationClientState< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + > +where + CredentialCreationOptions< + 'rp_id, + 'user_name, + 'user_display_name, + 'user_id, + 'prf_first, + 'prf_second, + USER_LEN, + >: Serialize, +{ + /// Serializes `self` according to [`CredentialCreationOptions::serialize`]. /// /// # Examples /// @@ -584,7 +742,7 @@ where /// # use webauthn_rp::{ /// # request::{ /// # register::{ - /// # FourToSixtyThree, UserHandle64, AuthenticatorAttachmentReq, PublicKeyCredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle + /// # FourToSixtyThree, UserHandle64, AuthenticatorAttachmentReq, CredentialCreationOptions, PublicKeyCredentialUserEntity, UserHandle /// # }, /// # AsciiDomain, ExtensionInfo, Hint, RpId, PublicKeyCredentialDescriptor, Credentials, UserVerificationRequirement, /// # }, @@ -609,75 +767,78 @@ where /// creds.push(PublicKeyCredentialDescriptor { id, transports }); /// let rp_id = RpId::Domain(AsciiDomain::try_from("example.com".to_owned())?); /// 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((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::RequireEnforceValue)); + /// let mut options = CredentialCreationOptions::passkey(&rp_id, PublicKeyCredentialUserEntity { name: "pierre.de.fermat".try_into()?, id: &user_handle, display_name: Some("Pierre de Fermat".try_into()?) }, creds); + /// options.public_key.authenticator_selection.authenticator_attachment = AuthenticatorAttachmentReq::None(Hint::SecurityKey); + /// options.public_key.extensions.min_pin_length = Some((FourToSixtyThree::new(16).unwrap_or_else(|| unreachable!("bug in FourToSixtyThree::new")), ExtensionInfo::RequireEnforceValue)); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap(); + /// let client_state = serde_json::to_string(&options.start_ceremony()?.1).unwrap_or_else(|_e| unreachable!("bug in RegistrationClientState::serialize")); /// let json = serde_json::json!({ - /// "rp":{ - /// "name":"example.com", - /// "id":"example.com" - /// }, - /// "user":{ - /// "name":"pierre.de.fermat", - /// "id":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - /// "displayName":"Pierre de Fermat" - /// }, - /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", - /// "pubKeyCredParams":[ - /// { - /// "type":"public-key", - /// "alg":-8 + /// "mediation":"optional", + /// "publicKey":{ + /// "rp":{ + /// "name":"example.com", + /// "id":"example.com" /// }, - /// { - /// "type":"public-key", - /// "alg":-7 + /// "user":{ + /// "name":"pierre.de.fermat", + /// "id":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + /// "displayName":"Pierre de Fermat" /// }, - /// { - /// "type":"public-key", - /// "alg":-35 + /// "challenge":"AAAAAAAAAAAAAAAAAAAAAA", + /// "pubKeyCredParams":[ + /// { + /// "type":"public-key", + /// "alg":-8 + /// }, + /// { + /// "type":"public-key", + /// "alg":-7 + /// }, + /// { + /// "type":"public-key", + /// "alg":-35 + /// }, + /// { + /// "type":"public-key", + /// "alg":-257 + /// }, + /// ], + /// "timeout":300000, + /// "excludeCredentials":[ + /// { + /// "type":"public-key", + /// "id":"AAAAAAAAAAAAAAAAAAAAAA", + /// "transports":["usb"] + /// } + /// ], + /// "authenticatorSelection":{ + /// "residentKey":"required", + /// "requireResidentKey":true, + /// "userVerification":"required" /// }, - /// { - /// "type":"public-key", - /// "alg":-257 - /// }, - /// ], - /// "timeout":300000, - /// "excludeCredentials":[ - /// { - /// "type":"public-key", - /// "id":"AAAAAAAAAAAAAAAAAAAAAA", - /// "transports":["usb"] + /// "hints":[ + /// "security-key" + /// ], + /// "attestation":"none", + /// "attestationFormats":[ + /// "none" + /// ], + /// "extensions":{ + /// "credentialProtectionPolicy":"userVerificationRequired", + /// "enforceCredentialProtectionPolicy":false, + /// "minPinLength":true /// } - /// ], - /// "authenticatorSelection":{ - /// "residentKey":"required", - /// "requireResidentKey":true, - /// "userVerification":"required" - /// }, - /// "hints":[ - /// "security-key" - /// ], - /// "attestation":"none", - /// "attestationFormats":[ - /// "none" - /// ], - /// "extensions":{ - /// "credentialProtectionPolicy":"userVerificationRequired", - /// "enforceCredentialProtectionPolicy":false, - /// "minPinLength":true /// } /// }).to_string(); /// // Since `Challenge`s are randomly generated, we don't know what it will be. /// // Similarly since we randomly generated a 64-byte `UserHandle`, we don't know what /// // it will be; thus we test the JSON string for everything except those two. /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(..88), json.get(..88)); + /// assert_eq!(client_state.get(..124), json.get(..124)); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(174..212), json.get(174..212)); + /// assert_eq!(client_state.get(210..259), json.get(210..259)); /// # #[cfg(all(feature = "bin", feature = "custom"))] - /// assert_eq!(client_state.get(245..), json.get(245..)); + /// assert_eq!(client_state.get(281..), json.get(281..)); /// # Ok::<_, webauthn_rp::AggErr>(()) /// ``` #[inline] @@ -685,53 +846,7 @@ where where S: Serializer, { - /// "none". - const NONE: &str = "none"; - serializer - .serialize_struct("RegistrationClientState", 11) - .and_then(|mut ser| { - ser.serialize_field("rp", &PublicKeyCredentialRpEntity(self.0.rp_id)) - .and_then(|()| { - ser.serialize_field("user", &self.0.user).and_then(|()| { - ser.serialize_field("challenge", &self.0.challenge) - .and_then(|()| { - ser.serialize_field( - "pubKeyCredParams", - &self.0.pub_key_cred_params, - ) - .and_then(|()| { - ser.serialize_field("timeout", &self.0.timeout).and_then( - |()| { - ser.serialize_field( - "excludeCredentials", - self.0.exclude_credentials.as_slice(), - ) - .and_then(|()| { - ser.serialize_field( - "authenticatorSelection", - &self.0.authenticator_selection, - ) - .and_then(|()| { - ser.serialize_field("hints", &match self.0.authenticator_selection.authenticator_attachment { - AuthenticatorAttachmentReq::None(hint) => hint, - AuthenticatorAttachmentReq::Platform(hint) => hint.into(), - AuthenticatorAttachmentReq::CrossPlatform(hint) => hint.into(), - }).and_then(|()| { - ser.serialize_field("attestation", NONE).and_then(|()| { - ser.serialize_field("attestationFormats", [NONE].as_slice()).and_then(|()| { - ser.serialize_field("extensions", &self.0.extensions).and_then(|()| ser.end()) - }) - }) - }) - }) - }) - }, - ) - }) - }) - }) - }) - }) + self.0.serialize(serializer) } } impl<'de: 'a, 'a> Deserialize<'de> for Nickname<'a> { @@ -853,25 +968,36 @@ where fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { formatter.write_str("UserHandle") } - #[expect(clippy::panic_in_result_fn, clippy::unreachable, reason = "we want to crash when there is a bug")] + #[expect( + clippy::panic_in_result_fn, + clippy::unreachable, + reason = "we want to crash when there is a bug" + )] fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> where E: Error, { // Any value between `USER_HANDLE_MIN_LEN` and `USER_HANDLE_MAX_LEN` can be base64url encoded // without fear since that range is just 1 to 64, and 4/3 of 64 is less than `usize::MAX`. - if crate::base64url_nopad_len(L).unwrap_or_else(|| unreachable!("there is a bug in webauthn_rp::base64url_nopad_len")) == v.len() { + if crate::base64url_nopad_len(L).unwrap_or_else(|| { + unreachable!("there is a bug in webauthn_rp::base64url_nopad_len") + }) == v.len() + { let mut data = [0; L]; BASE64URL_NOPAD .decode_mut(v.as_bytes(), data.as_mut_slice()) .map_err(|e| E::custom(e.error)) .map(|len| { - assert_eq!(len, L, "there is a bug in data_encoding::BASE64URL_NOPAD::decode_mut"); + assert_eq!( + len, L, + "there is a bug in data_encoding::BASE64URL_NOPAD::decode_mut" + ); UserHandle(data) }) } else { Err(E::invalid_value( - Unexpected::Str(v), &format!("{L} bytes encoded in base64url without padding").as_str() + Unexpected::Str(v), + &format!("{L} bytes encoded in base64url without padding").as_str(), )) } } @@ -903,7 +1029,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 @@ -3,9 +3,9 @@ use super::{ Decode, DecodeBuffer, EncDecErr, Encode, EncodeBuffer, EncodeBufferFallible as _, }, AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, CoseAlgorithmIdentifiers, - CredProtect, CredentialMediationRequirement, CrossPlatformHint, Extension, ExtensionInfo, Hint, - PlatformHint, RegistrationServerState, ResidentKeyRequirement, SentChallenge, UserHandle, - UserVerificationRequirement, + CredProtect, CredentialMediationRequirement, CrossPlatformHint, ExtensionInfo, Hint, + PlatformHint, RegistrationServerState, ResidentKeyRequirement, SentChallenge, + ServerExtensionInfo, ServerPrfInfo, UserHandle, UserVerificationRequirement, }; use core::{ error::Error, @@ -168,7 +168,33 @@ impl<'a> DecodeBuffer<'a> for CredProtect { }) } } -impl EncodeBuffer for Extension { +impl EncodeBuffer for ServerPrfInfo { + fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { + match *self { + Self::None => 0u8.encode_into_buffer(buffer), + Self::One(info) => { + 1u8.encode_into_buffer(buffer); + info.encode_into_buffer(buffer); + } + Self::Two(info) => { + 2u8.encode_into_buffer(buffer); + info.encode_into_buffer(buffer); + } + } + } +} +impl<'a> DecodeBuffer<'a> for ServerPrfInfo { + type Err = EncDecErr; + fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { + u8::decode_from_buffer(data).and_then(|val| match val { + 0 => Ok(Self::None), + 1 => ExtensionInfo::decode_from_buffer(data).map(Self::One), + 2 => ExtensionInfo::decode_from_buffer(data).map(Self::Two), + _ => Err(EncDecErr), + }) + } +} +impl EncodeBuffer for ServerExtensionInfo { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { self.cred_props.encode_into_buffer(buffer); self.cred_protect.encode_into_buffer(buffer); @@ -176,13 +202,13 @@ impl EncodeBuffer for Extension { self.prf.encode_into_buffer(buffer); } } -impl<'a> DecodeBuffer<'a> for Extension { +impl<'a> DecodeBuffer<'a> for ServerExtensionInfo { type Err = EncDecErr; fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { Option::decode_from_buffer(data).and_then(|cred_props| { CredProtect::decode_from_buffer(data).and_then(|cred_protect| { Option::decode_from_buffer(data).and_then(|min_pin_length| { - Option::decode_from_buffer(data).map(|prf| Self { + ServerPrfInfo::decode_from_buffer(data).map(|prf| Self { cred_props, cred_protect, min_pin_length, @@ -273,7 +299,7 @@ where CoseAlgorithmIdentifiers::decode_from_buffer(&mut input).map_err(|_e| DecodeRegistrationServerStateErr::Other).and_then(|pub_key_cred_params| { AuthenticatorSelectionCriteria::decode_from_buffer(&mut input).map_err(|_e| DecodeRegistrationServerStateErr::Other).and_then( |authenticator_selection| { - Extension::decode_from_buffer(&mut input).map_err(|_e| DecodeRegistrationServerStateErr::Other).and_then(|extensions| { + ServerExtensionInfo::decode_from_buffer(&mut input).map_err(|_e| DecodeRegistrationServerStateErr::Other).and_then(|extensions| { super::validate_options_helper(authenticator_selection, extensions) .map_err(|_e| DecodeRegistrationServerStateErr::Other) .and_then(|()| { diff --git a/src/request/ser.rs b/src/request/ser.rs @@ -1,9 +1,49 @@ use super::{ - Challenge, CredentialId, Hint, PublicKeyCredentialDescriptor, RpId, UserVerificationRequirement, + Challenge, CredentialId, CredentialMediationRequirement, Hint, PrfInput, + PublicKeyCredentialDescriptor, RpId, UserVerificationRequirement, }; use core::str; use data_encoding::BASE64URL_NOPAD; use serde::ser::{Serialize, SerializeSeq as _, SerializeStruct as _, Serializer}; +impl Serialize for CredentialMediationRequirement { + /// Serializes `self` to conform with + /// [`CredentialMediationRequirement`](https://www.w3.org/TR/credential-management-1/#enumdef-credentialmediationrequirement). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::CredentialMediationRequirement; + /// assert_eq!( + /// serde_json::to_string(&CredentialMediationRequirement::Silent)?, + /// r#""silent""# + /// ); + /// assert_eq!( + /// serde_json::to_string(&CredentialMediationRequirement::Optional)?, + /// r#""optional""# + /// ); + /// assert_eq!( + /// serde_json::to_string(&CredentialMediationRequirement::Conditional)?, + /// r#""conditional""# + /// ); + /// assert_eq!( + /// serde_json::to_string(&CredentialMediationRequirement::Required)?, + /// r#""required""# + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(match *self { + Self::Silent => "silent", + Self::Optional => "optional", + Self::Conditional => "conditional", + Self::Required => "required", + }) + } +} impl Serialize for Challenge { /// Serializes `self` to conform with /// [`challenge`](https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptionsjson-challenge). @@ -313,3 +353,48 @@ impl Serialize for Hint { }) } } +impl Serialize for PrfInput<'_, '_> { + /// Serializes `self` to conform with + /// [`AuthenticationExtensionsPRFValues`](https://www.w3.org/TR/webauthn-3/#dictdef-authenticationextensionsprfvalues). + /// + /// # Examples + /// + /// ``` + /// # use webauthn_rp::request::{PrfInput, ExtensionReq}; + /// assert_eq!( + /// serde_json::to_string(&PrfInput { + /// first: [0; 4].as_slice(), + /// second: Some([2; 1].as_slice()), + /// })?, + /// r#"{"first":"AAAAAA","second":"Ag"}"# + /// ); + /// # Ok::<_, serde_json::Error>(()) + /// ``` + #[expect( + clippy::arithmetic_side_effects, + reason = "comment justifies how overflow is not possible" + )] + #[inline] + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer + // The max value is 1 + 1 = 2, so overflow is not an issue. + .serialize_struct("PrfInput", 1 + usize::from(self.second.is_some())) + .and_then(|mut ser| { + ser.serialize_field("first", BASE64URL_NOPAD.encode(self.first).as_str()) + .and_then(|()| { + self.second + .as_ref() + .map_or(Ok(()), |second| { + ser.serialize_field( + "second", + BASE64URL_NOPAD.encode(second).as_str(), + ) + }) + .and_then(|()| ser.end()) + }) + }) + } +} diff --git a/src/response.rs b/src/response.rs @@ -99,12 +99,12 @@ use ser_relaxed::SerdeJsonErr; /// } /// /// Send `DiscoverableAuthenticationClientState` and receive `DiscoverableAuthentication64` JSON from client. /// # #[cfg(feature = "serde")] -/// fn get_authentication_json(client: DiscoverableAuthenticationClientState<'_, '_>) -> String { +/// fn get_authentication_json(client: DiscoverableAuthenticationClientState<'_, '_, '_>) -> String { /// // ⋮ /// # let client_data_json = BASE64URL_NOPAD.encode(serde_json::json!({ /// # "type": "webauthn.get", -/// # "challenge": client.options().0.challenge, -/// # "origin": format!("https://{}", client.options().0.rp_id.as_ref()), +/// # "challenge": client.options().public_key.challenge, +/// # "origin": format!("https://{}", client.options().public_key.rp_id.as_ref()), /// # "crossOrigin": false /// # }).to_string().as_bytes()); /// # serde_json::json!({ @@ -156,7 +156,7 @@ pub mod error; /// # use data_encoding::BASE64URL_NOPAD; /// # 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}, +/// # request::{register::{error::CreationOptionsErr, CredentialCreationOptions, PublicKeyCredentialUserEntity, RegistrationClientState, UserHandle, UserHandle64, USER_HANDLE_MAX_LEN, RegistrationVerificationOptions}, error::AsciiDomainErr, AsciiDomain, PublicKeyCredentialDescriptor, RpId}, /// # response::{register::{error::RegCeremonyErr, Registration}, error::CollectedClientDataErr, CollectedClientData}, /// # RegisteredCredential /// # }; @@ -204,7 +204,7 @@ pub mod error; /// # #[cfg(feature = "custom")] /// let creds = get_registered_credentials(user_handle); /// # #[cfg(feature = "custom")] -/// let (server, client) = PublicKeyCredentialCreationOptions::passkey(&rp_id, user, creds).start_ceremony()?; +/// let (server, client) = CredentialCreationOptions::passkey(&rp_id, user, creds).start_ceremony()?; /// # #[cfg(feature = "custom")] /// assert!( /// ceremonies.insert_remove_all_expired(server).map_or(false, convert::identity) @@ -237,12 +237,12 @@ pub mod error; /// } /// /// Send `RegistrationClientState` and receive `Registration` JSON from client. /// # #[cfg(feature = "serde")] -/// fn get_registration_json(client: RegistrationClientState<'_, '_, '_, '_, USER_HANDLE_MAX_LEN>) -> String { +/// fn get_registration_json(client: RegistrationClientState<'_, '_, '_, '_, '_, '_, USER_HANDLE_MAX_LEN>) -> String { /// // ⋮ /// # let client_data_json = BASE64URL_NOPAD.encode(serde_json::json!({ /// # "type": "webauthn.create", -/// # "challenge": client.options().challenge, -/// # "origin": format!("https://{}", client.options().rp_id.as_ref()), +/// # "challenge": client.options().public_key.challenge, +/// # "origin": format!("https://{}", client.options().public_key.rp_id.as_ref()), /// # "crossOrigin": false /// # }).to_string().as_bytes()); /// # serde_json::json!({ @@ -1531,6 +1531,8 @@ pub(super) enum CeremonyErr<AuthDataErr> { BackupEligible, /// [`Backup::NotEligible`] was sent back despite [`BackupReq::Eligible`]. BackupNotEligible, + /// [`Backup::Eligible`] was not sent back despite [`BackupReq::EligibleNoteExists`]. + BackupExists, /// [`Backup::Exists`] was not sent back despite [`BackupReq::Exists`]. BackupDoesNotExist, } @@ -1557,6 +1559,7 @@ impl<A: Display> Display for CeremonyErr<A> { Self::UserNotVerified => f.write_str("user was not verified despite being required to"), Self::BackupEligible => f.write_str("credential is eligible to be backed up despite requiring that it not be"), Self::BackupNotEligible => f.write_str("credential is not eligible to be backed up despite requiring that it be"), + Self::BackupExists => f.write_str("credential backup exists despite requiring that a backup not exist"), Self::BackupDoesNotExist => f.write_str("credential backup does not exist despite requiring that a backup exist"), } } @@ -1575,6 +1578,7 @@ impl From<CeremonyErr<AttestationObjectErr>> for RegCeremonyErr { CeremonyErr::UserNotVerified => Self::UserNotVerified, CeremonyErr::BackupEligible => Self::BackupEligible, CeremonyErr::BackupNotEligible => Self::BackupNotEligible, + CeremonyErr::BackupExists => Self::BackupExists, CeremonyErr::BackupDoesNotExist => Self::BackupDoesNotExist, } } @@ -1593,6 +1597,7 @@ impl From<CeremonyErr<AuthAuthDataErr>> for AuthCeremonyErr { CeremonyErr::UserNotVerified => Self::UserNotVerified, CeremonyErr::BackupEligible => Self::BackupEligible, CeremonyErr::BackupNotEligible => Self::BackupNotEligible, + CeremonyErr::BackupExists => Self::BackupExists, CeremonyErr::BackupDoesNotExist => Self::BackupDoesNotExist, } } @@ -1626,6 +1631,145 @@ pub struct CurrentUserDetailsOptions<'rp_id, 'name, 'display_name, 'id, const LE /// [`displayName`](https://www.w3.org/TR/webauthn-3/#dictdef-currentuserdetailsoptions-displayname). pub user: PublicKeyCredentialUserEntity<'name, 'display_name, 'id, LEN>, } +/// [`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) +/// during authentication and +/// [`hmac-secret-mc`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-make-cred-extension) +/// during registration. +/// +/// `REG` iff `hmac-secret-mc`. +enum HmacSecretGet<const REG: bool> { + /// No `hmac-secret` response. + None, + /// One encrypted `hmac-secret`. + One, + /// Two encrypted `hmac-secret`s. + Two, +} +/// Error returned by [`HmacSecretGet::from_cbor`] +enum HmacSecretGetErr { + /// Error related to the length of the CBOR input. + Len, + /// Error related to the type of the CBOR key. + Type, + /// Error related to the value of the CBOR value. + Value, +} +impl<const REG: bool> FromCbor<'_> for HmacSecretGet<REG> { + type Err = HmacSecretGetErr; + fn from_cbor(cbor: &[u8]) -> Result<CborSuccess<'_, Self>, Self::Err> { + /// AES block size. + const AES_BLOCK_SIZE: usize = 16; + /// HMAC-SHA-256 output length. + const HMAC_SHA_256_LEN: usize = 32; + /// Length of two HMAC-SHA-256 outputs concatenated together. + const TWO_HMAC_SHA_256_LEN: usize = HMAC_SHA_256_LEN << 1; + // We need the smallest multiple of `AES_BLOCK_SIZE` that + // is strictly greater than `HMAC_SHA_256_LEN`. + /// AES-256 output length on a 32-byte input. + #[expect( + clippy::integer_division_remainder_used, + reason = "doesn't need to be constant time" + )] + const ONE_SECRET_LEN: usize = + HMAC_SHA_256_LEN + (AES_BLOCK_SIZE - (HMAC_SHA_256_LEN % AES_BLOCK_SIZE)); + // We need the smallest multiple of `AES_BLOCK_SIZE` that + // is strictly greater than `TWO_HMAC_SHA_256_LEN`. + /// AES-256 output length on a 64-byte input. + #[expect( + clippy::integer_division_remainder_used, + reason = "doesn't need to be constant time" + )] + const TWO_SECRET_LEN: usize = + TWO_HMAC_SHA_256_LEN + (AES_BLOCK_SIZE - (TWO_HMAC_SHA_256_LEN % AES_BLOCK_SIZE)); + /// `hmac-secret-mc`. + /// + /// This is the key iff `REG`. + const KEY: [u8; 15] = [ + cbor::TEXT_14, + b'h', + b'm', + b'a', + b'c', + b'-', + b's', + b'e', + b'c', + b'r', + b'e', + b't', + b'-', + b'm', + b'c', + ]; + /// Helper that unifies `HmacSecretGet`. + enum CborVal<'a> { + /// Extension does not exist with remaining payload + Success, + /// Extension exists with remaining payload. + Continue(&'a [u8]), + } + if REG { + cbor.split_at_checked(KEY.len()).map_or( + Ok(CborVal::Success), + |(key, key_rem)| { + if key == KEY { + Ok(CborVal::Continue(key_rem)) + } else { + Ok(CborVal::Success) + } + } + ) + } else { + cbor.split_at_checked(cbor::HMAC_SECRET.len()).map_or( + Ok(CborVal::Success), + |(key, key_rem)| { + if key == cbor::HMAC_SECRET { + Ok(CborVal::Continue(key_rem)) + } else { + Ok(CborVal::Success) + } + } + ) + }.and_then(|cbor_val| { + match cbor_val { + CborVal::Success => Ok(CborSuccess { value: Self::None, remaining: cbor, }), + CborVal::Continue(key_rem) => { + key_rem + .split_first() + .ok_or(HmacSecretGetErr::Len) + .and_then(|(bytes, bytes_rem)| { + if *bytes == cbor::BYTES_INFO_24 { + bytes_rem + .split_first() + .ok_or(HmacSecretGetErr::Len) + .and_then(|(&len, len_rem)| { + len_rem.split_at_checked(usize::from(len)).ok_or(HmacSecretGetErr::Len).and_then(|(_, remaining)| { + match usize::from(len) { + ONE_SECRET_LEN => { + Ok(CborSuccess { + value: Self::One, + remaining, + }) + } + TWO_SECRET_LEN => { + Ok(CborSuccess { + value: Self::Two, + remaining, + }) + } + _ => Err(HmacSecretGetErr::Value), + } + }) + }) + } else { + Err(HmacSecretGetErr::Type) + } + }) + } + } + }) + } +} #[cfg(test)] mod tests { use super::{CollectedClientDataErr, ClientDataJsonParser, LimitedVerificationParser}; diff --git a/src/response/auth.rs b/src/response/auth.rs @@ -20,7 +20,8 @@ use super::{ super::{UserHandle, request::register::USER_HANDLE_MAX_LEN}, AuthData, AuthDataContainer, AuthExtOutput, AuthRespErr, AuthResponse, AuthenticatorAttachment, CborSuccess, ClientDataJsonParser as _, CollectedClientData, CredentialId, Flag, FromCbor, - LimitedVerificationParser, ParsedAuthData, Response, SentChallenge, + HmacSecretGet, HmacSecretGetErr, LimitedVerificationParser, ParsedAuthData, Response, + SentChallenge, auth::error::{AuthenticatorDataErr, AuthenticatorExtensionOutputErr, MissingUserHandleErr}, cbor, error::CollectedClientDataErr, @@ -78,74 +79,6 @@ pub enum HmacSecret { /// extension. Two, } -impl FromCbor<'_> for HmacSecret { - type Err = AuthenticatorExtensionOutputErr; - fn from_cbor(cbor: &[u8]) -> Result<CborSuccess<'_, Self>, Self::Err> { - /// AES block size. - const AES_BLOCK_SIZE: usize = 16; - /// HMAC-SHA-256 output length. - const HMAC_SHA_256_LEN: usize = 32; - /// Length of two HMAC-SHA-256 outputs concatenated together. - const TWO_HMAC_SHA_256_LEN: usize = HMAC_SHA_256_LEN << 1; - // We need the smallest multiple of `AES_BLOCK_SIZE` that - // is strictly greater than `HMAC_SHA_256_LEN`. - /// AES-256 output length on a 32-byte input. - #[expect( - clippy::integer_division_remainder_used, - reason = "doesn't need to be constant time" - )] - const ONE_SECRET_LEN: usize = - HMAC_SHA_256_LEN + (AES_BLOCK_SIZE - (HMAC_SHA_256_LEN % AES_BLOCK_SIZE)); - // We need the smallest multiple of `AES_BLOCK_SIZE` that - // is strictly greater than `TWO_HMAC_SHA_256_LEN`. - /// AES-256 output length on a 64-byte input. - #[expect( - clippy::integer_division_remainder_used, - reason = "doesn't need to be constant time" - )] - const TWO_SECRET_LEN: usize = - TWO_HMAC_SHA_256_LEN + (AES_BLOCK_SIZE - (TWO_HMAC_SHA_256_LEN % AES_BLOCK_SIZE)); - cbor.split_at_checked(cbor::HMAC_SECRET.len()) - .ok_or(AuthenticatorExtensionOutputErr::Len) - .and_then(|(key, key_rem)| { - if key == cbor::HMAC_SECRET { - key_rem - .split_first() - .ok_or(AuthenticatorExtensionOutputErr::Len) - .and_then(|(bytes, bytes_rem)| { - if *bytes == cbor::BYTES_INFO_24 { - bytes_rem - .split_first() - .ok_or(AuthenticatorExtensionOutputErr::Len) - .and_then(|(&len, len_rem)| { - len_rem.split_at_checked(usize::from(len)).ok_or(AuthenticatorExtensionOutputErr::Len).and_then(|(_, remaining)| { - match usize::from(len) { - ONE_SECRET_LEN => { - Ok(CborSuccess { - value: Self::One, - remaining, - }) - } - TWO_SECRET_LEN => { - Ok(CborSuccess { - value: Self::Two, - remaining, - }) - } - _ => Err(AuthenticatorExtensionOutputErr::HmacSecretValue), - } - }) - }) - } else { - Err(AuthenticatorExtensionOutputErr::HmacSecretType) - } - }) - } else { - Err(AuthenticatorExtensionOutputErr::Unsupported) - } - }) - } -} /// [Authenticator extension output](https://www.w3.org/TR/webauthn-3/#authenticator-extension-output). #[derive(Clone, Copy, Debug)] pub struct AuthenticatorExtensionOutput { @@ -157,6 +90,26 @@ impl AuthExtOutput for AuthenticatorExtensionOutput { matches!(self.hmac_secret, HmacSecret::None) } } +impl From<HmacSecretGetErr> for AuthenticatorExtensionOutputErr { + #[inline] + fn from(value: HmacSecretGetErr) -> Self { + match value { + HmacSecretGetErr::Len => Self::Len, + HmacSecretGetErr::Type => Self::HmacSecretType, + HmacSecretGetErr::Value => Self::HmacSecretValue, + } + } +} +impl From<HmacSecretGet<false>> for HmacSecret { + #[inline] + fn from(value: HmacSecretGet<false>) -> Self { + match value { + HmacSecretGet::None => Self::None, + HmacSecretGet::One => Self::One, + HmacSecretGet::Two => Self::Two, + } + } +} impl FromCbor<'_> for AuthenticatorExtensionOutput { type Err = AuthenticatorExtensionOutputErr; fn from_cbor(cbor: &[u8]) -> Result<CborSuccess<'_, Self>, Self::Err> { @@ -173,12 +126,14 @@ impl FromCbor<'_> for AuthenticatorExtensionOutput { }, |(map, map_rem)| { if *map == cbor::MAP_1 { - HmacSecret::from_cbor(map_rem).map(|success| CborSuccess { - value: Self { - hmac_secret: success.value, - }, - remaining: success.remaining, - }) + HmacSecretGet::from_cbor(map_rem) + .map_err(AuthenticatorExtensionOutputErr::from) + .map(|success| CborSuccess { + value: Self { + hmac_secret: success.value.into(), + }, + remaining: success.remaining, + }) } else { Err(AuthenticatorExtensionOutputErr::CborHeader) } diff --git a/src/response/auth/error.rs b/src/response/auth/error.rs @@ -235,6 +235,8 @@ pub enum AuthCeremonyErr { BackupEligible, /// [`Backup::NotEligible`] was sent back despite [`BackupReq::Eligible`]. BackupNotEligible, + /// [`Backup::Eligible`] was not sent back despite [`BackupReq::EligibleNotExists`]. + BackupExists, /// [`Backup::Exists`] was not sent back despite [`BackupReq::Exists`]. BackupDoesNotExist, /// [`AuthenticatorAttachment`] was not sent back despite being required. @@ -278,6 +280,7 @@ impl Display for AuthCeremonyErr { Self::TopOriginMismatch => CeremonyErr::<AuthenticatorDataErr>::TopOriginMismatch.fmt(f), Self::BackupEligible => CeremonyErr::<AuthenticatorDataErr>::BackupEligible.fmt(f), Self::BackupNotEligible => CeremonyErr::<AuthenticatorDataErr>::BackupNotEligible.fmt(f), + Self::BackupExists => CeremonyErr::<AuthenticatorDataErr>::BackupExists.fmt(f), Self::BackupDoesNotExist => CeremonyErr::<AuthenticatorDataErr>::BackupDoesNotExist.fmt(f), Self::ChallengeMismatch => CeremonyErr::<AuthenticatorDataErr>::ChallengeMismatch.fmt(f), Self::RpIdHashMismatch => CeremonyErr::<AuthenticatorDataErr>::RpIdHashMismatch.fmt(f), diff --git a/src/response/cbor.rs b/src/response/cbor.rs @@ -50,6 +50,8 @@ pub(super) const TEXT_8: u8 = TEXT | 8; pub(super) const TEXT_11: u8 = TEXT | 11; /// [`TEXT`] length `12`. pub(super) const TEXT_12: u8 = TEXT | 12; +/// [`TEXT`] length `14`. +pub(super) const TEXT_14: u8 = TEXT | 14; /// [`MAP`] length `0`. pub(super) const MAP_0: u8 = MAP; /// [`MAP`] length `1`. diff --git a/src/response/register.rs b/src/response/register.rs @@ -11,8 +11,8 @@ use super::{ super::request::register::{FourToSixtyThree, ResidentKeyRequirement}, AuthData, AuthDataContainer, AuthExtOutput, AuthRespErr, AuthResponse, AuthTransports, AuthenticatorAttachment, Backup, CborSuccess, ClientDataJsonParser as _, CollectedClientData, - CredentialId, Flag, FromCbor, LimitedVerificationParser, ParsedAuthData, Response, - SentChallenge, cbor, + CredentialId, Flag, FromCbor, HmacSecretGet, HmacSecretGetErr, LimitedVerificationParser, + ParsedAuthData, Response, SentChallenge, cbor, error::CollectedClientDataErr, register::error::{ AaguidErr, AttestationErr, AttestationObjectErr, AttestedCredentialDataErr, @@ -95,20 +95,41 @@ impl Display for CredentialProtectionPolicy { }) } } +#[expect(clippy::too_long_first_doc_paragraph, reason = "false positive")] +/// [`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) +/// and +/// [`hmac-secret-mc`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-make-cred-extension). +/// +/// Note `hmac-secret-mc` can only exist if `hmac-secret` exists with a value of `true`. +#[derive(Clone, Copy, Debug)] +pub enum HmacSecret { + /// No `hmac-secret` extension. + None, + /// `hmac-secret` extension with a value of `false`. + NotEnabled, + /// `hmac-secret` extension with a value of `true`. + Enabled, + /// `hmac-secret` extension with a value of `true` and `hmac-secret-mc` contained one encrypted PRF output. + One, + /// `hmac-secret` extension with a value of `true` and `hmac-secret-mc` contained two encrypted PRF outputs. + Two, +} /// [Authenticator extension output](https://www.w3.org/TR/webauthn-3/#authenticator-extension-output). #[derive(Clone, Copy, Debug)] pub struct AuthenticatorExtensionOutput { /// [`credProtect`](https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-credProtect-extension). pub cred_protect: CredentialProtectionPolicy, - /// [`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>, + /// [`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) + /// and + /// [`hmac-secret-mc`](https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html#sctn-hmac-secret-make-cred-extension). + pub hmac_secret: HmacSecret, /// [`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<FourToSixtyThree>, } impl AuthExtOutput for AuthenticatorExtensionOutput { fn missing(self) -> bool { matches!(self.cred_protect, CredentialProtectionPolicy::None) - && self.hmac_secret.is_none() + && matches!(self.hmac_secret, HmacSecret::None) && self.min_pin_length.is_none() } } @@ -119,6 +140,9 @@ pub struct AuthenticatorExtensionOutputStaticState { /// [`AuthenticatorExtensionOutput::cred_protect`]. pub cred_protect: CredentialProtectionPolicy, /// [`AuthenticatorExtensionOutput::hmac_secret`]. + /// + /// Note we only care about whether or not it has been enabled. Specifcally this is `None` iff + /// [`HmacSecret::None`], `Some(false)` iff [`HmacSecret::NotEnabled`]; otherwise `Some(true)`. pub hmac_secret: Option<bool>, } /// [`AuthenticatorExtensionOutput`] extensions that are saved in [`Metadata`] because they are purely informative @@ -141,7 +165,11 @@ impl From<AuthenticatorExtensionOutput> for AuthenticatorExtensionOutputStaticSt fn from(value: AuthenticatorExtensionOutput) -> Self { Self { cred_protect: value.cred_protect, - hmac_secret: value.hmac_secret, + hmac_secret: match value.hmac_secret { + HmacSecret::None => None, + HmacSecret::NotEnabled => Some(false), + HmacSecret::Enabled | HmacSecret::One | HmacSecret::Two => Some(true), + }, } } } @@ -201,13 +229,13 @@ impl FromCbor<'_> for CredentialProtectionPolicy { } } /// [`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). -enum HmacSecret { +enum HmacSecretEnabled { /// No `hmac-secret` extension. None, /// `hmac-secret` set to the contained `bool`. Val(bool), } -impl FromCbor<'_> for HmacSecret { +impl FromCbor<'_> for HmacSecretEnabled { type Err = AuthenticatorExtensionOutputErr; fn from_cbor(cbor: &[u8]) -> Result<CborSuccess<'_, Self>, Self::Err> { cbor.split_at_checked(cbor::HMAC_SECRET.len()).map_or( @@ -312,6 +340,16 @@ impl FromCbor<'_> for MinPinLength { ) } } +impl From<HmacSecretGetErr> for AuthenticatorExtensionOutputErr { + #[inline] + fn from(value: HmacSecretGetErr) -> Self { + match value { + HmacSecretGetErr::Len => Self::Len, + HmacSecretGetErr::Type => Self::HmacSecretMcType, + HmacSecretGetErr::Value => Self::HmacSecretMcValue, + } + } +} impl FromCbor<'_> for AuthenticatorExtensionOutput { type Err = AuthenticatorExtensionOutputErr; #[expect( @@ -320,16 +358,18 @@ impl FromCbor<'_> for AuthenticatorExtensionOutput { )] fn from_cbor(cbor: &[u8]) -> Result<CborSuccess<'_, Self>, Self::Err> { // We don't allow unsupported extensions; thus the only possibilities is any ordered element of - // the power set of {"credProtect":<1, 2, or 3>, "hmac-secret":<true or false>, "minPinLength":<0-255>}. + // the power set of {"credProtect":<1, 2, or 3>, "hmac-secret":<true or false>, "minPinLength":<0-255>, "hmac-secret-mc":<48|80 bytes>}. // Since the keys are the same type (text), order is first done based on length; and then // byte-wise lexical order is followed; thus `credProtect` must come before `hmac-secret` which - // must come before `minPinLength`. + // must come before `minPinLength` which comes before `hmac-secret-mc`. + // + // Note `hmac-secret-mc` can only exist if `hmac-secret` exists with a value of `true`. cbor.split_first().map_or_else( || { Ok(CborSuccess { value: Self { cred_protect: CredentialProtectionPolicy::None, - hmac_secret: None, + hmac_secret: HmacSecret::None, min_pin_length: None, }, remaining: cbor, @@ -339,39 +379,46 @@ impl FromCbor<'_> for AuthenticatorExtensionOutput { cbor::MAP_1 => { CredentialProtectionPolicy::from_cbor(map_rem).and_then(|cred_success| { if matches!(cred_success.value, CredentialProtectionPolicy::None) { - HmacSecret::from_cbor(cred_success.remaining).and_then(|hmac_success| { - match hmac_success.value { - HmacSecret::None => MinPinLength::from_cbor( + HmacSecretEnabled::from_cbor(cred_success.remaining).and_then( + |hmac_success| match hmac_success.value { + HmacSecretEnabled::None => MinPinLength::from_cbor( hmac_success.remaining, ) .and_then(|pin_success| match pin_success.value { MinPinLength::None => { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. Err(AuthenticatorExtensionOutputErr::Missing) } MinPinLength::Val(min_pin_len) => Ok(CborSuccess { value: Self { cred_protect: cred_success.value, - hmac_secret: None, + hmac_secret: HmacSecret::None, min_pin_length: Some(min_pin_len), }, remaining: pin_success.remaining, }), }), - HmacSecret::Val(hmac) => Ok(CborSuccess { + HmacSecretEnabled::Val(hmac) => Ok(CborSuccess { value: Self { cred_protect: cred_success.value, - hmac_secret: Some(hmac), + hmac_secret: if hmac { + HmacSecret::Enabled + } else { + HmacSecret::NotEnabled + }, min_pin_length: None, }, remaining: hmac_success.remaining, }), - } - }) + }, + ) } else { Ok(CborSuccess { value: Self { cred_protect: cred_success.value, - hmac_secret: None, + hmac_secret: HmacSecret::None, min_pin_length: None, }, remaining: cred_success.remaining, @@ -382,89 +429,275 @@ impl FromCbor<'_> for AuthenticatorExtensionOutput { cbor::MAP_2 => { CredentialProtectionPolicy::from_cbor(map_rem).and_then(|cred_success| { if matches!(cred_success.value, CredentialProtectionPolicy::None) { - HmacSecret::from_cbor(cred_success.remaining).and_then(|hmac_success| { - match hmac_success.value { - HmacSecret::None => { + HmacSecretEnabled::from_cbor(cred_success.remaining).and_then( + |hmac_success| match hmac_success.value { + HmacSecretEnabled::None => { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. Err(AuthenticatorExtensionOutputErr::Missing) } - HmacSecret::Val(hmac) => MinPinLength::from_cbor( + HmacSecretEnabled::Val(hmac) => MinPinLength::from_cbor( hmac_success.remaining, ) .and_then(|pin_success| match pin_success.value { MinPinLength::None => { - Err(AuthenticatorExtensionOutputErr::Missing) + if hmac { + HmacSecretGet::<true>::from_cbor( + pin_success.remaining, + ) + .map_err(AuthenticatorExtensionOutputErr::from) + .and_then(|hmac_get| match hmac_get.value { + HmacSecretGet::None => Err( + AuthenticatorExtensionOutputErr::Missing, + ), + HmacSecretGet::One => Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::One, + min_pin_length: None, + }, + remaining: hmac_get.remaining, + }), + HmacSecretGet::Two => Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::Two, + min_pin_length: None, + }, + remaining: hmac_get.remaining, + }), + }) + } else { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. + Err(AuthenticatorExtensionOutputErr::Missing) + } } MinPinLength::Val(min_pin_len) => Ok(CborSuccess { value: Self { cred_protect: cred_success.value, - hmac_secret: Some(hmac), + hmac_secret: if hmac { + HmacSecret::Enabled + } else { + HmacSecret::NotEnabled + }, min_pin_length: Some(min_pin_len), }, remaining: pin_success.remaining, }), }), - } - }) + }, + ) } else { - HmacSecret::from_cbor(cred_success.remaining).and_then(|hmac_success| { - match hmac_success.value { - HmacSecret::None => MinPinLength::from_cbor( + HmacSecretEnabled::from_cbor(cred_success.remaining).and_then( + |hmac_success| match hmac_success.value { + HmacSecretEnabled::None => MinPinLength::from_cbor( hmac_success.remaining, ) .and_then(|pin_success| match pin_success.value { MinPinLength::None => { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. Err(AuthenticatorExtensionOutputErr::Missing) } MinPinLength::Val(min_pin_len) => Ok(CborSuccess { value: Self { cred_protect: cred_success.value, - hmac_secret: None, + hmac_secret: HmacSecret::None, min_pin_length: Some(min_pin_len), }, remaining: pin_success.remaining, }), }), - HmacSecret::Val(hmac) => Ok(CborSuccess { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. + HmacSecretEnabled::Val(hmac) => Ok(CborSuccess { value: Self { cred_protect: cred_success.value, - hmac_secret: Some(hmac), + hmac_secret: if hmac { + HmacSecret::Enabled + } else { + HmacSecret::NotEnabled + }, min_pin_length: None, }, remaining: hmac_success.remaining, }), - } - }) + }, + ) } }) } cbor::MAP_3 => { CredentialProtectionPolicy::from_cbor(map_rem).and_then(|cred_success| { if matches!(cred_success.value, CredentialProtectionPolicy::None) { + HmacSecretEnabled::from_cbor(cred_success.remaining).and_then( + |hmac_success| match hmac_success.value { + HmacSecretEnabled::None => { + Err(AuthenticatorExtensionOutputErr::Missing) + } + HmacSecretEnabled::Val(hmac) => { + if hmac { + MinPinLength::from_cbor( + hmac_success.remaining, + ) + .and_then(|pin_success| match pin_success.value { + MinPinLength::None => Err(AuthenticatorExtensionOutputErr::Missing), + MinPinLength::Val(min_pin_len) => HmacSecretGet::<true>::from_cbor(pin_success.remaining).map_err(AuthenticatorExtensionOutputErr::from).and_then(|hmac_get| { + match hmac_get.value { + HmacSecretGet::None => Err(AuthenticatorExtensionOutputErr::Missing), + HmacSecretGet::One => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::One, + min_pin_length: Some(min_pin_len), + }, + remaining: hmac_get.remaining, + }) + } + HmacSecretGet::Two => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::Two, + min_pin_length: Some(min_pin_len), + }, + remaining: hmac_get.remaining, + }) + } + } + }) + }) + } else { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. + Err(AuthenticatorExtensionOutputErr::Missing) + } + } + }, + ) + } else { + HmacSecretEnabled::from_cbor(cred_success.remaining).and_then( + |hmac_success| match hmac_success.value { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. + HmacSecretEnabled::None => Err(AuthenticatorExtensionOutputErr::Missing), + HmacSecretEnabled::Val(hmac) => { + MinPinLength::from_cbor(hmac_success.remaining).and_then(|pin_success| { + match pin_success.value { + MinPinLength::None => { + if hmac { + HmacSecretGet::<true>::from_cbor(pin_success.remaining).map_err(AuthenticatorExtensionOutputErr::from).and_then(|hmac_get| { + match hmac_get.value { + HmacSecretGet::None => Err(AuthenticatorExtensionOutputErr::Missing), + HmacSecretGet::One => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::One, + min_pin_length: None, + }, + remaining: hmac_get.remaining, + }) + } + HmacSecretGet::Two => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::Two, + min_pin_length: None, + }, + remaining: hmac_get.remaining, + }) + } + } + }) + } else { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. + Err(AuthenticatorExtensionOutputErr::Missing) + } + } + MinPinLength::Val(min_pin_len) => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: if hmac { HmacSecret::Enabled } else { HmacSecret::NotEnabled }, + min_pin_length: Some(min_pin_len), + }, + remaining: pin_success.remaining, + }) + } + } + }) + } + } + ) + } + }) + } + cbor::MAP_4 => { + CredentialProtectionPolicy::from_cbor(map_rem).and_then(|cred_success| { + if matches!(cred_success.value, CredentialProtectionPolicy::None) { Err(AuthenticatorExtensionOutputErr::Missing) } else { - HmacSecret::from_cbor(cred_success.remaining).and_then(|hmac_success| { - match hmac_success.value { - HmacSecret::None => { + HmacSecretEnabled::from_cbor(cred_success.remaining).and_then( + |hmac_success| match hmac_success.value { + HmacSecretEnabled::None => { Err(AuthenticatorExtensionOutputErr::Missing) } - HmacSecret::Val(hmac) => MinPinLength::from_cbor( - hmac_success.remaining, - ) - .and_then(|pin_success| match pin_success.value { - MinPinLength::None => { + HmacSecretEnabled::Val(hmac) => { + if hmac { + MinPinLength::from_cbor( + hmac_success.remaining, + ) + .and_then(|pin_success| match pin_success.value { + MinPinLength::None => { + Err(AuthenticatorExtensionOutputErr::Missing) + } + MinPinLength::Val(min_pin_len) => HmacSecretGet::<true>::from_cbor(pin_success.remaining).map_err(AuthenticatorExtensionOutputErr::from).and_then(|hmac_get| { + match hmac_get.value { + HmacSecretGet::None => Err(AuthenticatorExtensionOutputErr::Missing), + HmacSecretGet::One => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::One, + min_pin_length: Some(min_pin_len), + }, + remaining: hmac_get.remaining, + }) + } + HmacSecretGet::Two => { + Ok(CborSuccess { + value: Self { + cred_protect: cred_success.value, + hmac_secret: HmacSecret::Two, + min_pin_length: Some(min_pin_len), + }, + remaining: hmac_get.remaining, + }) + } + } + }) + }) + } else { + // We don't even bother checking for `HmacSecretGet` since + // it's only valid when `HmacSecretEnabled` exists with a value + // of `true`. Err(AuthenticatorExtensionOutputErr::Missing) } - MinPinLength::Val(min_pin_len) => Ok(CborSuccess { - value: Self { - cred_protect: cred_success.value, - hmac_secret: Some(hmac), - min_pin_length: Some(min_pin_len), - }, - remaining: pin_success.remaining, - }), - }), - } - }) + } + }, + ) } }) } @@ -3214,8 +3447,14 @@ mod tests { }, auth::{AuthenticatorData, NonDiscoverableAuthenticatorAssertion}, }, - AttestationFormat, AttestationObject, AuthDataContainer as _, AuthTransports, - AuthenticatorAttestation, Backup, Sig, UncompressedPubKey, + AttestationFormat, AttestationObject, AuthDataContainer as _, AuthExtOutput as _, + AuthTransports, AuthenticatorAttestation, AuthenticatorExtensionOutput, + AuthenticatorExtensionOutputErr, Backup, CborSuccess, CredentialProtectionPolicy, + FromCbor as _, HmacSecret, Sig, UncompressedPubKey, + cbor::{ + BYTES, BYTES_INFO_24, MAP_1, MAP_2, MAP_3, MAP_4, SIMPLE_FALSE, SIMPLE_TRUE, TEXT_11, + TEXT_12, TEXT_14, + }, }; use data_encoding::HEXLOWER; use ed25519_dalek::Verifier as _; @@ -3378,4 +3617,193 @@ mod tests { assert!(key.verify(msg.as_slice(), &sig).is_ok()); Ok(()) } + struct AuthExtOptions<'a> { + cred_protect: Option<u8>, + hmac_secret: Option<bool>, + min_pin_length: Option<u8>, + hmac_secret_mc: Option<&'a [u8]>, + } + fn generate_auth_extensions(opts: AuthExtOptions<'_>) -> Vec<u8> { + let map_len = u8::from(opts.cred_protect.is_some()) + + u8::from(opts.hmac_secret.is_some()) + + u8::from(opts.min_pin_length.is_some()) + + u8::from(opts.hmac_secret_mc.is_some()); + let header = match map_len { + 0 => return Vec::new(), + 1 => MAP_1, + 2 => MAP_2, + 3 => MAP_3, + 4 => MAP_4, + _ => unreachable!("bug"), + }; + let mut cbor = Vec::with_capacity(128); + cbor.push(header); + if let Some(protect) = opts.cred_protect { + cbor.push(TEXT_11); + cbor.extend_from_slice(b"credProtect".as_slice()); + if protect >= 24 { + cbor.push(24); + } + cbor.push(protect); + } + if let Some(hmac) = opts.hmac_secret { + cbor.push(TEXT_11); + cbor.extend_from_slice(b"hmac-secret".as_slice()); + cbor.push(if hmac { SIMPLE_TRUE } else { SIMPLE_FALSE }); + } + if let Some(pin) = opts.min_pin_length { + cbor.push(TEXT_12); + cbor.extend_from_slice(b"minPinLength".as_slice()); + if pin >= 24 { + cbor.push(24); + } + cbor.push(pin); + } + if let Some(mc) = opts.hmac_secret_mc { + cbor.push(TEXT_14); + cbor.extend_from_slice(b"hmac-secret-mc".as_slice()); + match mc.len() { + len @ ..=23 => { + cbor.push(BYTES | len as u8); + } + len @ 24..=255 => { + cbor.push(BYTES_INFO_24); + cbor.push(len as u8); + } + _ => panic!( + "AuthExtOptions does not allow hmac_secret_mc to have length greater than 255" + ), + } + cbor.extend_from_slice(mc); + } + cbor + } + #[test] + fn test_auth_ext() -> Result<(), AuthenticatorExtensionOutputErr> { + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: None, + min_pin_length: None, + hmac_secret_mc: None, + }); + let CborSuccess { value, remaining } = + AuthenticatorExtensionOutput::from_cbor(opts.as_slice())?; + assert!(remaining.is_empty()); + assert!(value.missing()); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: None, + min_pin_length: None, + hmac_secret_mc: Some([0; 48].as_slice()), + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::Missing), + |_| false, + ) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: Some(true), + min_pin_length: None, + hmac_secret_mc: Some([0; 48].as_slice()), + }); + let CborSuccess { value, remaining } = + AuthenticatorExtensionOutput::from_cbor(opts.as_slice())?; + assert!(remaining.is_empty()); + assert!( + matches!(value.cred_protect, CredentialProtectionPolicy::None) + && matches!(value.hmac_secret, HmacSecret::One) + && value.min_pin_length.is_none() + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: Some(false), + min_pin_length: None, + hmac_secret_mc: Some([0; 48].as_slice()), + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::Missing), + |_| false, + ) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: Some(true), + min_pin_length: None, + hmac_secret_mc: Some([0; 49].as_slice()), + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::HmacSecretMcValue), + |_| false, + ) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: Some(true), + min_pin_length: None, + hmac_secret_mc: Some([0; 23].as_slice()), + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::HmacSecretMcType), + |_| false, + ) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: Some(1), + hmac_secret: Some(true), + min_pin_length: Some(5), + hmac_secret_mc: Some([0; 48].as_slice()), + }); + let CborSuccess { value, remaining } = + AuthenticatorExtensionOutput::from_cbor(opts.as_slice())?; + assert!(remaining.is_empty()); + assert!( + matches!( + value.cred_protect, + CredentialProtectionPolicy::UserVerificationOptional + ) && matches!(value.hmac_secret, HmacSecret::One) + && value.min_pin_length.is_some_and(|pin| pin.value() == 5) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: Some(0), + hmac_secret: None, + min_pin_length: None, + hmac_secret_mc: None, + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::CredProtectValue), + |_| false, + ) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: None, + min_pin_length: Some(3), + hmac_secret_mc: None, + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::MinPinLengthValue), + |_| false, + ) + ); + let opts = generate_auth_extensions(AuthExtOptions { + cred_protect: None, + hmac_secret: None, + min_pin_length: Some(64), + hmac_secret_mc: None, + }); + assert!( + AuthenticatorExtensionOutput::from_cbor(opts.as_slice()).map_or_else( + |e| matches!(e, AuthenticatorExtensionOutputErr::MinPinLengthValue), + |_| false, + ) + ); + Ok(()) + } } diff --git a/src/response/register/error.rs b/src/response/register/error.rs @@ -7,9 +7,9 @@ use super::{ request::{ BackupReq, CredentialMediationRequirement, UserVerificationRequirement, register::{ - AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, Extension, - PublicKeyCredentialCreationOptions, RegistrationServerState, - RegistrationVerificationOptions, + AuthenticatorAttachmentReq, AuthenticatorSelectionCriteria, + CredentialCreationOptions, Extension, PublicKeyCredentialCreationOptions, + RegistrationServerState, RegistrationVerificationOptions, }, }, }, @@ -178,6 +178,10 @@ pub enum AuthenticatorExtensionOutputErr { HmacSecretValue, /// `minPinLength` had an invalid value. MinPinLengthValue, + /// `hmac-secret-mc` was not a byte string with additional info 24. + HmacSecretMcType, + /// `hmac-secret-mc` was not a byte string of length 48 or 80. + HmacSecretMcValue, /// Fewer extensions existed than expected. Missing, } @@ -190,6 +194,8 @@ impl Display for AuthenticatorExtensionOutputErr { Self::CredProtectValue => "CBOR authenticator extension 'credProtect' had an invalid value", Self::HmacSecretValue => "CBOR authenticator extension 'hmac-secret' had an invalid value", Self::MinPinLengthValue => "CBOR authenticator extension 'minPinLength' had an invalid value", + Self::HmacSecretMcType => "CBOR authenticator extension 'hmac-secret-mc' was not a byte string with additional info 24", + Self::HmacSecretMcValue => "CBOR authenticator extension 'hmac-secret-mc' was not a byte string of length 48 or 80", Self::Missing => "CBOR authenticator extensions had fewer extensions than expected", }) } @@ -562,7 +568,7 @@ pub enum RegCeremonyErr { ChallengeMismatch, /// The SHA-256 hash of [`PublicKeyCredentialCreationOptions::rp_id`] does not match [`AuthenticatorData::rp_id_hash`]. RpIdHashMismatch, - /// [`Flag::user_present`] was `false` despite [`PublicKeyCredentialCreationOptions::mediation`] + /// [`Flag::user_present`] was `false` despite [`CredentialCreationOptions::mediation`] /// being something other than [`CredentialMediationRequirement::Conditional`]. UserNotPresent, /// [`AuthenticatorSelectionCriteria::user_verification`] was set to [`UserVerificationRequirement::Required`], @@ -572,6 +578,8 @@ pub enum RegCeremonyErr { BackupEligible, /// [`Backup::NotEligible`] was sent back despite [`BackupReq::Eligible`]. BackupNotEligible, + /// [`Backup::Eligible`] was not sent back despite [`BackupReq::EligibleNotExists`]. + BackupExists, /// [`Backup::Exists`] was not sent back despite [`BackupReq::Exists`]. BackupDoesNotExist, /// [`AuthenticatorAttachment`] was not sent back despite being required. @@ -603,6 +611,7 @@ impl Display for RegCeremonyErr { Self::TopOriginMismatch => CeremonyErr::<AttestationObjectErr>::TopOriginMismatch.fmt(f), Self::BackupEligible => CeremonyErr::<AttestationObjectErr>::BackupEligible.fmt(f), Self::BackupNotEligible => CeremonyErr::<AttestationObjectErr>::BackupNotEligible.fmt(f), + Self::BackupExists => CeremonyErr::<AttestationObjectErr>::BackupExists.fmt(f), Self::BackupDoesNotExist => CeremonyErr::<AttestationObjectErr>::BackupDoesNotExist.fmt(f), Self::ChallengeMismatch => CeremonyErr::<AttestationObjectErr>::ChallengeMismatch.fmt(f), Self::RpIdHashMismatch => CeremonyErr::<AttestationObjectErr>::RpIdHashMismatch.fmt(f),