From 74cf5c0f0eab7abc984e21ec15e1f74782a53ea0 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 20:59:03 +0100 Subject: [PATCH 1/2] fix(webauthn): refuse U2F downgrade when credProtect enforced or largeBlob required MakeCredentialRequest::is_downgradable() previously rejected only when ES256 was unsupported, resident_key was Required, or user_verification was Required. Requests carrying credProtect with enforce_policy or largeBlob: required were silently downgraded into U2F credentials that could not honour those guarantees. Extend the predicate to also refuse the downgrade when: - extensions.cred_protect.enforce_policy is true and policy is stricter than UserVerificationOptional; - extensions.large_blob.support == Required. Both checks mirror the FIDO2 path's existing enforcement in Ctap2MakeCredentialsRequestExtensions::from_webauthn_request. Refs: WebAuthn L3 section 10.1.5; CTAP 2.1 sections 10.2 and 12.1. --- .../src/ops/webauthn/make_credential.rs | 26 ++++++ libwebauthn/src/ops/webauthn/mod.rs | 88 ++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 03d2b6d..237d65c 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -599,6 +599,32 @@ impl DowngradableRequest for MakeCredentialRequest { return false; } + // Enforced credProtect with a non-default policy cannot be honoured by U2F. + if let Some(cred_protect) = self + .extensions + .as_ref() + .and_then(|e| e.cred_protect.as_ref()) + { + if cred_protect.enforce_policy + && cred_protect.policy != CredentialProtectionPolicy::UserVerificationOptional + { + debug!("Not downgradable: request enforces a non-default credProtect policy"); + return false; + } + } + + // U2F has no large-blob storage. + if matches!( + self.extensions + .as_ref() + .and_then(|e| e.large_blob.as_ref()) + .map(|lb| lb.support), + Some(MakeCredentialLargeBlobExtension::Required) + ) { + debug!("Not downgradable: request requires the largeBlob extension"); + return false; + } + true } diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 2a57e67..8407440 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -82,7 +82,11 @@ pub trait DowngradableRequest { #[cfg(test)] mod tests { - use crate::ops::webauthn::make_credential::ResidentKeyRequirement; + use crate::ops::webauthn::make_credential::{ + CredentialProtectionExtension, CredentialProtectionPolicy, + MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, + MakeCredentialsRequestExtensions, ResidentKeyRequirement, + }; use crate::ops::webauthn::{ DowngradableRequest, MakeCredentialRequest, UserVerificationRequirement, }; @@ -123,4 +127,86 @@ mod tests { )]; assert!(!request.is_downgradable()); } + + #[test] + fn ctap2_make_credential_downgradable_enforced_cred_protect_required() { + let mut request = MakeCredentialRequest::dummy(); + request.algorithms = vec![Ctap2CredentialType::default()]; + request.extensions = Some(MakeCredentialsRequestExtensions { + cred_protect: Some(CredentialProtectionExtension { + policy: CredentialProtectionPolicy::UserVerificationRequired, + enforce_policy: true, + }), + ..MakeCredentialsRequestExtensions::default() + }); + assert!(!request.is_downgradable()); + } + + #[test] + fn ctap2_make_credential_downgradable_enforced_cred_protect_optional_with_list() { + let mut request = MakeCredentialRequest::dummy(); + request.algorithms = vec![Ctap2CredentialType::default()]; + request.extensions = Some(MakeCredentialsRequestExtensions { + cred_protect: Some(CredentialProtectionExtension { + policy: CredentialProtectionPolicy::UserVerificationOptionalWithCredentialIDList, + enforce_policy: true, + }), + ..MakeCredentialsRequestExtensions::default() + }); + assert!(!request.is_downgradable()); + } + + #[test] + fn ctap2_make_credential_downgradable_enforced_cred_protect_optional() { + let mut request = MakeCredentialRequest::dummy(); + request.algorithms = vec![Ctap2CredentialType::default()]; + request.extensions = Some(MakeCredentialsRequestExtensions { + cred_protect: Some(CredentialProtectionExtension { + policy: CredentialProtectionPolicy::UserVerificationOptional, + enforce_policy: true, + }), + ..MakeCredentialsRequestExtensions::default() + }); + assert!(request.is_downgradable()); + } + + #[test] + fn ctap2_make_credential_downgradable_non_enforced_cred_protect() { + let mut request = MakeCredentialRequest::dummy(); + request.algorithms = vec![Ctap2CredentialType::default()]; + request.extensions = Some(MakeCredentialsRequestExtensions { + cred_protect: Some(CredentialProtectionExtension { + policy: CredentialProtectionPolicy::UserVerificationRequired, + enforce_policy: false, + }), + ..MakeCredentialsRequestExtensions::default() + }); + assert!(request.is_downgradable()); + } + + #[test] + fn ctap2_make_credential_downgradable_large_blob_required() { + let mut request = MakeCredentialRequest::dummy(); + request.algorithms = vec![Ctap2CredentialType::default()]; + request.extensions = Some(MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Required, + }), + ..MakeCredentialsRequestExtensions::default() + }); + assert!(!request.is_downgradable()); + } + + #[test] + fn ctap2_make_credential_downgradable_large_blob_preferred() { + let mut request = MakeCredentialRequest::dummy(); + request.algorithms = vec![Ctap2CredentialType::default()]; + request.extensions = Some(MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Preferred, + }), + ..MakeCredentialsRequestExtensions::default() + }); + assert!(request.is_downgradable()); + } } From 690e326ca4132b177392ee1b4917bf8ff7eac770 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 10 May 2026 21:03:47 +0100 Subject: [PATCH 2/2] fix(webauthn): enforce largeBlob: required on FIDO2 path when device lacks largeBlobs Previously the FIDO2 path emitted a warn! when extensions.largeBlob.support == Required and the device did not advertise the largeBlobs option, then forwarded the request as if largeBlob were preferred. The device returned success but ignored the extension, leaving the caller with no signal that the requested extension was dropped. Return Err(CtapError::UnsupportedExtension) in this case, mirroring the existing credProtect enforcement immediately above. Also re-export MakeCredentialLargeBlobExtensionInput from ops::webauthn so the type required to construct MakeCredentialsRequestExtensions::large_blob is part of the public API surface. Refs: WebAuthn L3 section 10.1.5; CTAP 2.1 section 12.1. --- libwebauthn/src/ops/webauthn/mod.rs | 9 +- .../src/proto/ctap2/model/make_credential.rs | 92 ++++++++++++++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 8407440..36ab56b 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -25,10 +25,11 @@ pub use idl::{ }; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, - MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionOutput, - MakeCredentialPrfInput, MakeCredentialPrfOutput, MakeCredentialRequest, MakeCredentialResponse, - MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions, - MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement, + MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput, + MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrfInput, MakeCredentialPrfOutput, + MakeCredentialRequest, MakeCredentialResponse, MakeCredentialsRequestExtensions, + MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions, + ResidentKeyRequirement, }; pub use psl::{DatFileLoadError, DatFilePublicSuffixList, PublicSuffixList, SYSTEM_PSL_PATH}; use serde::Deserialize; diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index db798d8..75714d2 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -231,12 +231,10 @@ impl Ctap2MakeCredentialsRequestExtensions { .map(|info| info.support) { Some(MakeCredentialLargeBlobExtension::Required) => { - // "required": The credential will be created with an authenticator to store blobs. The create() call will fail if this is impossible. + // Required + unsupported must fail rather than silently degrade. if !info.option_enabled("largeBlobs") { - warn!("This request will potentially fail. Large blob extension required, but device does not support it."); + return Err(Error::Ctap(CtapError::UnsupportedExtension)); } - // We still send the request to the device and let it sort it out. - // We only add a warning for easier debugging. Some(true) } Some(MakeCredentialLargeBlobExtension::Preferred) => { @@ -386,3 +384,89 @@ pub struct Ctap2MakeCredentialsResponseExtensions { #[serde(default, skip_serializing_if = "Option::is_none")] pub min_pin_length: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::ops::webauthn::MakeCredentialLargeBlobExtensionInput; + use std::collections::HashMap; + + fn info_with_options(options: &[(&str, bool)]) -> Ctap2GetInfoResponse { + let mut info = Ctap2GetInfoResponse::default(); + let mut map = HashMap::new(); + for (k, v) in options { + map.insert((*k).to_string(), *v); + } + info.options = Some(map); + info + } + + #[test] + fn ctap2_extensions_large_blob_required_unsupported_returns_unsupported_extension() { + let info = info_with_options(&[("largeBlobs", false)]); + let requested = MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Required, + }), + ..MakeCredentialsRequestExtensions::default() + }; + + let result = + Ctap2MakeCredentialsRequestExtensions::from_webauthn_request(&requested, &info); + assert!(matches!( + result, + Err(Error::Ctap(CtapError::UnsupportedExtension)) + )); + } + + #[test] + fn ctap2_extensions_large_blob_required_option_absent_returns_unsupported_extension() { + // No options at all (largeBlobs neither present nor enabled). + let info = Ctap2GetInfoResponse::default(); + let requested = MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Required, + }), + ..MakeCredentialsRequestExtensions::default() + }; + + let result = + Ctap2MakeCredentialsRequestExtensions::from_webauthn_request(&requested, &info); + assert!(matches!( + result, + Err(Error::Ctap(CtapError::UnsupportedExtension)) + )); + } + + #[test] + fn ctap2_extensions_large_blob_required_supported_returns_some_true() { + let info = info_with_options(&[("largeBlobs", true)]); + let requested = MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Required, + }), + ..MakeCredentialsRequestExtensions::default() + }; + + let extensions = + Ctap2MakeCredentialsRequestExtensions::from_webauthn_request(&requested, &info) + .unwrap(); + assert_eq!(extensions.large_blob_key, Some(true)); + } + + #[test] + fn ctap2_extensions_large_blob_preferred_unsupported_omits_request() { + let info = info_with_options(&[("largeBlobs", false)]); + let requested = MakeCredentialsRequestExtensions { + large_blob: Some(MakeCredentialLargeBlobExtensionInput { + support: MakeCredentialLargeBlobExtension::Preferred, + }), + ..MakeCredentialsRequestExtensions::default() + }; + + let extensions = + Ctap2MakeCredentialsRequestExtensions::from_webauthn_request(&requested, &info) + .unwrap(); + assert_eq!(extensions.large_blob_key, None); + } +}