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:
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,
},