webauthn_rp

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

commit e3ca43d238ba05e85889aadb5d23ca80fec219d9
parent 407bd6d6d8592aa03f3c3ffa8eb478d0b4c0fb72
Author: Zack Newman <zack@philomathiclife.com>
Date:   Fri, 10 Apr 2026 08:56:17 -0600

require backup eligibility to be immutable

Diffstat:
Msrc/request.rs | 38+-------------------------------------
Msrc/request/auth.rs | 51+++++++++++++++++++++++++++++++++++----------------
Msrc/request/auth/tests.rs | 6+++---
Msrc/request/register.rs | 24+++++++++++++++++++++---
Msrc/request/tests.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/response.rs | 2+-
Msrc/response/auth/error.rs | 17+++++++++--------
Msrc/response/register.rs | 9++++++---
Msrc/response/register/bin.rs | 28++++++++++++++--------------
Msrc/response/register/error.rs | 4++--
10 files changed, 197 insertions(+), 92 deletions(-)

diff --git a/src/request.rs b/src/request.rs @@ -19,7 +19,7 @@ use crate::{ error::{ AsciiDomainErr, DomainOriginParseErr, PortParseErr, RpIdErr, SchemeParseErr, UrlErr, }, - register::RegistrationVerificationOptions, + register::{BackupReq, RegistrationVerificationOptions}, }, response::{ AuthData as _, AuthDataContainer, AuthResponse, AuthTransports, Backup, CeremonyErr, @@ -1419,42 +1419,6 @@ pub enum CredentialMediationRequirement { /// can be explicitly performed during the ceremony. Conditional, } -/// Backup requirements for the credential. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum BackupReq { - /// 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`]; 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, -} -impl From<Backup> for BackupReq { - /// One may want to create `BackupReq` based on the previous `Backup` such that the subsequent `Backup` is - /// essentially unchanged. - /// - /// Specifically this transforms [`Backup::NotEligible`] to [`Self::NotEligible`] and [`Backup::Eligible`] and - /// [`Backup::Exists`] to [`Self::Eligible`]. Note this means that a credential that - /// is eligible to be backed up but currently does not have a backup will be allowed to change such that it - /// is backed up. Similarly, a credential that is backed up is allowed to change such that a backup no longer - /// exists. - #[inline] - fn from(value: Backup) -> Self { - if matches!(value, Backup::NotEligible) { - Self::NotEligible - } else { - Self::Eligible - } - } -} /// A container of "credentials". /// /// This is mainly a way to unify [`Vec`] of [`PublicKeyCredentialDescriptor`] diff --git a/src/request/auth.rs b/src/request/auth.rs @@ -3,7 +3,7 @@ mod tests; #[cfg(doc)] use super::{ super::response::{ - Backup, CollectedClientData, Flag, + CollectedClientData, Flag, auth::AuthenticatorData, register::{ AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, @@ -26,9 +26,10 @@ use super::{ register::{CompressedPubKey, CredentialProtectionPolicy}, }, }, - BackupReq, Ceremony, CeremonyOptions, Challenge, CredentialId, CredentialMediationRequirement, - Credentials, ExtensionReq, FIVE_MINUTES, Hints, Origin, PrfInput, - PublicKeyCredentialDescriptor, RpId, SentChallenge, TimedCeremony, UserVerificationRequirement, + Backup, BackupReq, Ceremony, CeremonyOptions, Challenge, CredentialId, + CredentialMediationRequirement, Credentials, ExtensionReq, FIVE_MINUTES, Hints, Origin, + PrfInput, PublicKeyCredentialDescriptor, RpId, SentChallenge, TimedCeremony, + UserVerificationRequirement, auth::error::{InvalidTimeout, NonDiscoverableCredentialRequestOptionsErr}, }; use core::{ @@ -1003,6 +1004,23 @@ impl Default for AuthenticatorAttachmentEnforcement { Self::Ignore(false) } } +/// Backup state requirements for the credential. +/// +/// Note the backup eligibility of a credential is not allowed to change. This is only used to allow one to +/// require the existence or lack of existence of a backup. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum BackupStateReq { + /// No requirements on the backup state. + /// + /// The backup eligibility of the credential must be the same as required by the spec, but the existence + /// of a backup doesn't matter. + #[default] + None, + /// Credential must be backed up. + Exists, + /// Credential must not be backed up. + DoesntExist, +} /// Additional verification options to perform in [`DiscoverableAuthenticationServerState::verify`] and /// [`NonDiscoverableAuthenticationServerState::verify`]. #[derive(Clone, Copy, Debug)] @@ -1023,12 +1041,7 @@ pub struct AuthenticationVerificationOptions<'origins, 'top_origins, O, T> { /// `CollectedClientData::cross_origin` must be `false` and `CollectedClientData::top_origin` must be `None`. pub allowed_top_origins: Option<&'top_origins [T]>, /// The required [`Backup`] state of the credential. - /// - /// Note that `None` is _not_ the same as `Some(BackupReq::None)` as the latter indicates that any [`Backup`] - /// is allowed. This is rarely what you want; instead, `None` indicates that [`BackupReq::from`] applied to - /// [`DynamicState::backup`] will be used in [`DiscoverableAuthenticationServerState::verify`] and - /// [`NonDiscoverableAuthenticationServerState::verify`] and - pub backup_requirement: Option<BackupReq>, + pub backup_state_requirement: BackupStateReq, /// Error when unsolicited extensions are sent back iff `true`. pub error_on_unsolicited_extensions: bool, /// Dictates what happens when [`Authentication::authenticator_attachment`] is not the same as @@ -1044,8 +1057,8 @@ pub struct AuthenticationVerificationOptions<'origins, 'top_origins, O, T> { } impl<O, T> AuthenticationVerificationOptions<'_, '_, O, T> { /// Returns `Self` such that [`Self::allowed_origins`] is empty, [`Self::allowed_top_origins`] is `None`, - /// [`Self::backup_requirement`] is `None`, [`Self::error_on_unsolicited_extensions`] is `true`, - /// [`Self::auth_attachment_enforcement`] is [`AuthenticatorAttachmentEnforcement::default`], + /// [`Self::backup_state_requirement`] is [`BackupStateReq::None`], [`Self::error_on_unsolicited_extensions`] is + /// `true`, [`Self::auth_attachment_enforcement`] is [`AuthenticatorAttachmentEnforcement::default`], /// [`Self::update_uv`] is `false`, [`Self::sig_counter_enforcement`] is /// [`SignatureCounterEnforcement::default`], and [`Self::client_data_json_relaxed`] is `true`. /// @@ -1056,7 +1069,7 @@ impl<O, T> AuthenticationVerificationOptions<'_, '_, O, T> { Self { allowed_origins: [].as_slice(), allowed_top_origins: None, - backup_requirement: None, + backup_state_requirement: BackupStateReq::None, error_on_unsolicited_extensions: true, auth_attachment_enforcement: AuthenticatorAttachmentEnforcement::Ignore(false), update_uv: false, @@ -1343,9 +1356,15 @@ impl AuthenticationServerState { &CeremonyOptions { allowed_origins: options.allowed_origins, allowed_top_origins: options.allowed_top_origins, - backup_requirement: options - .backup_requirement - .unwrap_or_else(|| BackupReq::from(cred.dynamic_state.backup)), + backup_requirement: if cred.dynamic_state.backup == Backup::NotEligible { + BackupReq::NotEligible + } else { + match options.backup_state_requirement { + BackupStateReq::None => BackupReq::Eligible, + BackupStateReq::Exists => BackupReq::Exists, + BackupStateReq::DoesntExist => BackupReq::EligibleNotExists, + } + }, #[cfg(feature = "serde_relaxed")] client_data_json_relaxed: options.client_data_json_relaxed, }, diff --git a/src/request/auth/tests.rs b/src/request/auth/tests.rs @@ -13,8 +13,8 @@ use super::{ }, }, AuthCeremonyErr, AuthenticationVerificationOptions, AuthenticatorAttachment, - AuthenticatorAttachmentEnforcement, DiscoverableAuthentication, ExtensionErr, OneOrTwo, - PrfInput, SignatureCounterEnforcement, + AuthenticatorAttachmentEnforcement, BackupStateReq, DiscoverableAuthentication, ExtensionErr, + OneOrTwo, PrfInput, SignatureCounterEnforcement, }; #[cfg(all( feature = "custom", @@ -292,7 +292,7 @@ fn validate(options: TestOptions) -> Result<(), AggErr> { allowed_origins: [].as_slice(), allowed_top_origins: None, auth_attachment_enforcement: AuthenticatorAttachmentEnforcement::Update(false), - backup_requirement: None, + backup_state_requirement: BackupStateReq::None, error_on_unsolicited_extensions: options.request.error_unsolicited, sig_counter_enforcement: SignatureCounterEnforcement::Fail, update_uv: false, diff --git a/src/request/register.rs b/src/request/register.rs @@ -13,9 +13,9 @@ use super::{ }, }, }, - BackupReq, Ceremony, Challenge, CredentialMediationRequirement, ExtensionInfo, ExtensionReq, - FIVE_MINUTES, Hints, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, SentChallenge, - TimedCeremony, UserVerificationRequirement, + Ceremony, Challenge, CredentialMediationRequirement, ExtensionInfo, ExtensionReq, FIVE_MINUTES, + Hints, Origin, PrfInput, PublicKeyCredentialDescriptor, RpId, SentChallenge, TimedCeremony, + UserVerificationRequirement, register::error::CreationOptionsErr, }; #[cfg(doc)] @@ -53,6 +53,24 @@ pub mod ser; /// Contains functionality to (de)serialize [`RegistrationServerState`] to a data store. #[cfg(feature = "serializable_server_state")] pub mod ser_server_state; +/// Backup requirements for the credential. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum BackupReq { + /// 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`]; 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, +} /// Used by [`Extension::cred_protect`] to enforce the [`CredentialProtectionPolicy`] sent by the client via /// [`Registration`]. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] diff --git a/src/request/tests.rs b/src/request/tests.rs @@ -131,7 +131,7 @@ fn eddsa_reg() -> Result<(), AggErr> { prf: Some(( PrfInput { first: [0].as_slice(), - second: None, + second: Some(&[]), }, ExtensionInfo::RequireEnforceValue, )), @@ -248,8 +248,8 @@ fn eddsa_reg() -> Result<(), AggErr> { b't', b'a', CBOR_BYTES | 24, - // Length is 154. - 154, + // Length is 251. + 251, // RP ID HASH. // This will be overwritten later. 0, @@ -383,7 +383,7 @@ fn eddsa_reg() -> Result<(), AggErr> { 0, 0, 0, - CBOR_MAP | 3, + CBOR_MAP | 4, CBOR_TEXT | 11, b'c', b'r', @@ -426,6 +426,105 @@ fn eddsa_reg() -> Result<(), AggErr> { b't', b'h', CBOR_UINT | 16, + // 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, + // Length is 80. + 80, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, ] .as_slice(), ); @@ -437,7 +536,8 @@ fn eddsa_reg() -> Result<(), AggErr> { attestation_object[188..220].copy_from_slice(pub_key); let sig = sig_key.sign(&attestation_object[107..]); attestation_object[32..96].copy_from_slice(sig.to_bytes().as_slice()); - attestation_object.truncate(261); + let att_obj_len = attestation_object.len() - 32; + attestation_object.truncate(att_obj_len); assert!(matches!(opts.start_ceremony()?.0.verify( RP_ID, &Registration { diff --git a/src/response.rs b/src/response.rs @@ -33,7 +33,7 @@ use ser_relaxed::SerdeJsonErr; /// # use core::convert; /// # use webauthn_rp::{ /// # hash::hash_set::{InsertRemoveExpired, MaxLenHashSet}, -/// # request::{auth::{error::InvalidTimeout, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, register::{UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, BackupReq, RpId}, +/// # request::{auth::{error::InvalidTimeout, DiscoverableAuthenticationClientState, DiscoverableCredentialRequestOptions, AuthenticationVerificationOptions}, register::{BackupReq, UserHandle, USER_HANDLE_MAX_LEN, UserHandle64}, RpId}, /// # response::{auth::{error::AuthCeremonyErr, DiscoverableAuthentication64}, error::CollectedClientDataErr, register::{AuthenticatorExtensionOutputStaticState, ClientExtensionsOutputsStaticState, CredentialProtectionPolicy, DynamicState, Ed25519PubKey, CompressedPubKeyOwned, StaticState}, AuthenticatorAttachment, Backup, CollectedClientData, CredentialId}, /// # AuthenticatedCredential, CredentialErr /// # }; diff --git a/src/response/auth/error.rs b/src/response/auth/error.rs @@ -10,12 +10,13 @@ use super::{ super::{ AuthenticatedCredential, DynamicState, StaticState, request::{ - BackupReq, UserVerificationRequirement, + UserVerificationRequirement, auth::{ AllowedCredential, AllowedCredentials, AuthenticationVerificationOptions, - CredentialSpecificExtension, DiscoverableAuthenticationServerState, - DiscoverableCredentialRequestOptions, Extension, - NonDiscoverableAuthenticationServerState, PublicKeyCredentialRequestOptions, + BackupStateReq, CredentialSpecificExtension, + DiscoverableAuthenticationServerState, DiscoverableCredentialRequestOptions, + Extension, NonDiscoverableAuthenticationServerState, + PublicKeyCredentialRequestOptions, }, }, }, @@ -231,13 +232,13 @@ pub enum AuthCeremonyErr { /// [`PublicKeyCredentialRequestOptions::user_verification`] was set to /// [`UserVerificationRequirement::Required`], but [`Flag::user_verified`] was `false`. UserNotVerified, - /// [`Backup::NotEligible`] was not sent back despite [`BackupReq::NotEligible`]. + /// [`Backup::Eligible`] was sent back despite the credential not being eligible to be backed up. BackupEligible, - /// [`Backup::NotEligible`] was sent back despite [`BackupReq::Eligible`]. + /// [`Backup::NotEligible`] was sent back despite the credential being eligible to be backed up. BackupNotEligible, - /// [`Backup::Eligible`] was not sent back despite [`BackupReq::EligibleNotExists`]. + /// [`Backup::Exists`] was sent back despite [`BackupStateReq::DoesntExist`]. BackupExists, - /// [`Backup::Exists`] was not sent back despite [`BackupReq::Exists`]. + /// [`Backup::Eligible`] was sent back despite [`BackupStateReq::Exists`]. BackupDoesNotExist, /// [`AuthenticatorAttachment`] was not sent back despite being required. MissingAuthenticatorAttachment, diff --git a/src/response/register.rs b/src/response/register.rs @@ -30,8 +30,11 @@ use super::{ AuthenticatedCredential, RegisteredCredential, hash::hash_set::MaxLenHashSet, request::{ - BackupReq, Challenge, UserVerificationRequirement, - auth::{AuthenticationVerificationOptions, PublicKeyCredentialRequestOptions}, + Challenge, UserVerificationRequirement, + auth::{ + AuthenticationVerificationOptions, BackupStateReq, + PublicKeyCredentialRequestOptions, + }, register::{CoseAlgorithmIdentifier, Extension, RegistrationServerState}, }, }, @@ -4130,7 +4133,7 @@ pub struct DynamicState { /// enforce user verification for all ceremonies, [`UserVerificationRequirement::Required`] must always be /// sent. pub user_verified: bool, - /// This can only be updated if [`BackupReq`] allows for it. + /// This can only be updated if [`BackupStateReq`] allows for it. pub backup: Backup, /// [`signCount`](https://www.w3.org/TR/webauthn-3/#authdata-signcount). /// diff --git a/src/response/register/bin.rs b/src/response/register/bin.rs @@ -248,31 +248,31 @@ impl EncodeBuffer for UncompressedPubKey<'_> { fn encode_into_buffer(&self, buffer: &mut Vec<u8>) { match *self { Self::MlDsa87(key) => { - 0u8.encode_into_buffer(buffer); + 4u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } Self::MlDsa65(key) => { - 1u8.encode_into_buffer(buffer); + 5u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } Self::MlDsa44(key) => { - 2u8.encode_into_buffer(buffer); + 6u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } Self::Ed25519(key) => { - 3u8.encode_into_buffer(buffer); + 0u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } Self::P256(key) => { - 4u8.encode_into_buffer(buffer); + 1u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } Self::P384(key) => { - 5u8.encode_into_buffer(buffer); + 2u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } Self::Rsa(key) => { - 6u8.encode_into_buffer(buffer); + 3u8.encode_into_buffer(buffer); key.encode_into_buffer(buffer); } } @@ -288,13 +288,13 @@ impl<'a> DecodeBuffer<'a> for CompressedPubKeyOwned { // benefit in performing "expensive" validation checks. fn decode_from_buffer(data: &mut &'a [u8]) -> Result<Self, Self::Err> { u8::decode_from_buffer(data).and_then(|val| match val { - 0 => MlDsa87PubKey::decode_from_buffer(data).map(Self::MlDsa87), - 1 => MlDsa65PubKey::decode_from_buffer(data).map(Self::MlDsa65), - 2 => MlDsa44PubKey::decode_from_buffer(data).map(Self::MlDsa44), - 3 => Ed25519PubKey::decode_from_buffer(data).map(Self::Ed25519), - 4 => CompressedP256PubKey::decode_from_buffer(data).map(Self::P256), - 5 => CompressedP384PubKey::decode_from_buffer(data).map(Self::P384), - 6 => RsaPubKey::decode_from_buffer(data).map(Self::Rsa), + 0 => Ed25519PubKey::decode_from_buffer(data).map(Self::Ed25519), + 1 => CompressedP256PubKey::decode_from_buffer(data).map(Self::P256), + 2 => CompressedP384PubKey::decode_from_buffer(data).map(Self::P384), + 3 => RsaPubKey::decode_from_buffer(data).map(Self::Rsa), + 4 => MlDsa87PubKey::decode_from_buffer(data).map(Self::MlDsa87), + 5 => MlDsa65PubKey::decode_from_buffer(data).map(Self::MlDsa65), + 6 => MlDsa44PubKey::decode_from_buffer(data).map(Self::MlDsa44), _ => Err(EncDecErr), }) } diff --git a/src/response/register/error.rs b/src/response/register/error.rs @@ -5,9 +5,9 @@ use super::{ super::super::{ RegisteredCredential, request::{ - BackupReq, CredentialMediationRequirement, UserVerificationRequirement, + CredentialMediationRequirement, UserVerificationRequirement, register::{ - AuthenticatorSelectionCriteria, CredentialCreationOptions, Extension, + AuthenticatorSelectionCriteria, BackupReq, CredentialCreationOptions, Extension, PublicKeyCredentialCreationOptions, RegistrationServerState, RegistrationVerificationOptions, },