Skip to content

Commit 1878449

Browse files
feat(webauthn): force UV=required when PRF extension is requested (fix #183)
WebAuthn PRF outputs are sensitive key material. Per the spec direction in w3c/webauthn#2337, callers requesting the PRF extension must have userVerification upgraded to "required" before the request reaches the authenticator. This applies regardless of whether PRF eval values are populated -- presence of the extension is the trigger. The upgrade happens at the public WebAuthn API entry (webauthn.rs), so both JSON-IDL and direct-struct callers are covered. The CTAP2-level hmac-secret extension keeps its existing separate UV / non-UV seed behaviour and is not affected. Notable behaviour changes for callers: - Discouraged or Preferred + PRF now triggers UV (PIN setup if no PIN). - U2F-only devices were already incapable of PRF; PRF-bearing requests now error with NegotiationFailed instead of silently dropping PRF.
1 parent 8556676 commit 1878449

2 files changed

Lines changed: 265 additions & 4 deletions

File tree

libwebauthn-tests/tests/prf.rs

Lines changed: 192 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use libwebauthn::ops::webauthn::{
88
use libwebauthn::pin::PinManagement;
99
use libwebauthn::proto::ctap2::{Ctap2PinUvAuthProtocol, Ctap2PublicKeyCredentialDescriptor};
1010
use libwebauthn::transport::hid::channel::HidChannel;
11-
use libwebauthn::transport::{Channel, Device};
11+
use libwebauthn::transport::{Channel, Ctap2AuthTokenStore, Device};
1212
use libwebauthn::webauthn::{Error as WebAuthnError, PlatformError, WebAuthn};
1313
use libwebauthn::UvUpdate;
1414
use libwebauthn::{
@@ -70,6 +70,7 @@ async fn test_webauthn_prf_with_pin_set_forced_pin_protocol_two() {
7070
enum UvUpdateShim {
7171
PresenceRequired,
7272
PinRequired,
73+
PinNotSet,
7374
}
7475

7576
async fn handle_updates(
@@ -90,6 +91,13 @@ async fn handle_updates(
9091
panic!("Did not get PinRequired-update as expected!");
9192
}
9293
}
94+
UvUpdateShim::PinNotSet => {
95+
if let UvUpdate::PinNotSet(update) = update {
96+
let _ = update.set_pin("1234");
97+
} else {
98+
panic!("Did not get PinNotSet-update as expected!");
99+
}
100+
}
93101
}
94102
}
95103
state_recv
@@ -122,9 +130,13 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
122130
let state_recv = channel.get_ux_update_receiver();
123131

124132
let mut expected_updates = Vec::new();
125-
// First make cred
133+
// First make cred: PRF forces userVerification=required (W3C webauthn#2337),
134+
// so without a PIN we must drive the interactive PIN setup flow.
126135
if using_pin {
127136
expected_updates.push(UvUpdateShim::PinRequired);
137+
} else {
138+
expected_updates.push(UvUpdateShim::PinNotSet);
139+
expected_updates.push(UvUpdateShim::PinRequired);
128140
}
129141
expected_updates.push(UvUpdateShim::PresenceRequired); // First MakeCredential
130142

@@ -612,8 +624,11 @@ async fn test_webauthn_prf_variable_length_input() {
612624

613625
let state_recv = channel.get_ux_update_receiver();
614626
let expected_updates = vec![
627+
// PRF forces UV=required (webauthn#2337); no-PIN device drives PIN setup.
628+
UvUpdateShim::PinNotSet, // MakeCredential: set PIN
629+
UvUpdateShim::PinRequired, // MakeCredential: auth with new PIN
615630
UvUpdateShim::PresenceRequired, // MakeCredential
616-
UvUpdateShim::PresenceRequired, // assert empty
631+
UvUpdateShim::PresenceRequired, // assert empty (cached pinUvAuthToken)
617632
UvUpdateShim::PresenceRequired, // assert 7 bytes
618633
UvUpdateShim::PresenceRequired, // assert 100 bytes
619634
UvUpdateShim::PresenceRequired, // determinism re-check (same 7 bytes)
@@ -708,3 +723,177 @@ async fn test_webauthn_prf_variable_length_input() {
708723
let mut state_recv = uv_handle.await.unwrap();
709724
assert_eq!(state_recv.try_recv(), Err(TryRecvError::Empty));
710725
}
726+
727+
fn basic_make_credential_request(
728+
user_id: &[u8; 32],
729+
challenge: &[u8; 32],
730+
user_verification: UserVerificationRequirement,
731+
extensions: Option<MakeCredentialsRequestExtensions>,
732+
) -> MakeCredentialRequest {
733+
MakeCredentialRequest {
734+
origin: "example.org".to_owned(),
735+
challenge: Vec::from(challenge.as_slice()),
736+
relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"),
737+
user: Ctap2PublicKeyCredentialUserEntity::new(user_id, "mario.rossi", "Mario Rossi"),
738+
resident_key: Some(ResidentKeyRequirement::Discouraged),
739+
user_verification,
740+
algorithms: vec![Ctap2CredentialType::default()],
741+
exclude: None,
742+
extensions,
743+
timeout: TIMEOUT,
744+
top_origin: None,
745+
}
746+
}
747+
748+
// W3C webauthn#2337: PRF presence forces userVerification=required. With a PIN
749+
// already set, Discouraged + PRF must now trigger the PIN auth flow (PinRequired)
750+
// instead of being skipped as it would have been pre-upgrade.
751+
#[test(tokio::test)]
752+
async fn test_webauthn_prf_upgrades_uv_at_registration() {
753+
let mut device = get_virtual_device();
754+
let mut channel = device.channel().await.unwrap();
755+
channel.change_pin("1234".into(), TIMEOUT).await.unwrap();
756+
757+
let state_recv = channel.get_ux_update_receiver();
758+
let updates = tokio::spawn(handle_updates(
759+
state_recv,
760+
vec![UvUpdateShim::PinRequired, UvUpdateShim::PresenceRequired],
761+
));
762+
763+
let user_id: [u8; 32] = thread_rng().gen();
764+
let challenge: [u8; 32] = thread_rng().gen();
765+
let request = basic_make_credential_request(
766+
&user_id,
767+
&challenge,
768+
UserVerificationRequirement::Discouraged,
769+
Some(MakeCredentialsRequestExtensions {
770+
prf: Some(MakeCredentialPrfInput { _eval: None }),
771+
..Default::default()
772+
}),
773+
);
774+
775+
let response = channel
776+
.webauthn_make_credential(&request)
777+
.await
778+
.expect("Failed to register credential");
779+
assert_eq!(
780+
response.unsigned_extensions_output.prf,
781+
Some(MakeCredentialPrfOutput {
782+
enabled: Some(true)
783+
})
784+
);
785+
786+
let mut state_recv = updates.await.unwrap();
787+
assert_eq!(state_recv.try_recv(), Err(TryRecvError::Empty));
788+
}
789+
790+
// Negative: without PRF, Discouraged + PIN-set device must NOT trigger the PIN
791+
// flow. Guards against the upgrade leaking into non-PRF requests.
792+
#[test(tokio::test)]
793+
async fn test_webauthn_no_prf_no_upgrade() {
794+
let mut device = get_virtual_device();
795+
let mut channel = device.channel().await.unwrap();
796+
channel.change_pin("1234".into(), TIMEOUT).await.unwrap();
797+
798+
let state_recv = channel.get_ux_update_receiver();
799+
let updates = tokio::spawn(handle_updates(
800+
state_recv,
801+
vec![UvUpdateShim::PresenceRequired],
802+
));
803+
804+
let user_id: [u8; 32] = thread_rng().gen();
805+
let challenge: [u8; 32] = thread_rng().gen();
806+
let request = basic_make_credential_request(
807+
&user_id,
808+
&challenge,
809+
UserVerificationRequirement::Discouraged,
810+
None,
811+
);
812+
813+
channel
814+
.webauthn_make_credential(&request)
815+
.await
816+
.expect("Failed to register credential");
817+
818+
let mut state_recv = updates.await.unwrap();
819+
assert_eq!(state_recv.try_recv(), Err(TryRecvError::Empty));
820+
}
821+
822+
// W3C webauthn#2337: same upgrade applies at assertion time. We clear the
823+
// cached PinUvAuthToken between registration and assertion so the assertion
824+
// must obtain fresh UV; without the clear, the cached (mc|ga, rpid) token
825+
// would cover the assertion regardless of whether the upgrade engaged.
826+
#[test(tokio::test)]
827+
async fn test_webauthn_prf_upgrades_uv_at_assertion() {
828+
let mut device = get_virtual_device();
829+
let mut channel = device.channel().await.unwrap();
830+
channel.change_pin("1234".into(), TIMEOUT).await.unwrap();
831+
832+
let user_id: [u8; 32] = thread_rng().gen();
833+
let challenge: [u8; 32] = thread_rng().gen();
834+
835+
let registration = basic_make_credential_request(
836+
&user_id,
837+
&challenge,
838+
UserVerificationRequirement::Required,
839+
Some(MakeCredentialsRequestExtensions {
840+
prf: Some(MakeCredentialPrfInput { _eval: None }),
841+
..Default::default()
842+
}),
843+
);
844+
let state_recv = channel.get_ux_update_receiver();
845+
let setup_updates = tokio::spawn(handle_updates(
846+
state_recv,
847+
vec![UvUpdateShim::PinRequired, UvUpdateShim::PresenceRequired],
848+
));
849+
let response = channel
850+
.webauthn_make_credential(&registration)
851+
.await
852+
.expect("Failed to register credential");
853+
let state_recv = setup_updates.await.unwrap();
854+
855+
let credential: Ctap2PublicKeyCredentialDescriptor =
856+
(&response.authenticator_data).try_into().unwrap();
857+
858+
channel.clear_uv_auth_token_store();
859+
860+
let prf = PrfInput {
861+
eval: Some(PrfInputValue {
862+
first: vec![1; 32],
863+
second: None,
864+
}),
865+
eval_by_credential: HashMap::new(),
866+
};
867+
let get_assertion = GetAssertionRequest {
868+
relying_party_id: "example.org".to_owned(),
869+
origin: "example.org".to_owned(),
870+
challenge: Vec::from(challenge),
871+
allow: vec![credential],
872+
user_verification: UserVerificationRequirement::Discouraged,
873+
extensions: Some(GetAssertionRequestExtensions {
874+
prf: Some(prf),
875+
..Default::default()
876+
}),
877+
timeout: TIMEOUT,
878+
top_origin: None,
879+
};
880+
let assertion_updates = tokio::spawn(handle_updates(
881+
state_recv,
882+
vec![UvUpdateShim::PinRequired, UvUpdateShim::PresenceRequired],
883+
));
884+
let assertion = channel
885+
.webauthn_get_assertion(&get_assertion)
886+
.await
887+
.expect("Failed to get assertion");
888+
let prf_output = assertion.assertions[0]
889+
.unsigned_extensions_output
890+
.as_ref()
891+
.expect("Missing unsigned_extensions_output")
892+
.prf
893+
.as_ref()
894+
.expect("Missing PRF output");
895+
assert!(prf_output.results.is_some());
896+
897+
let mut state_recv = assertion_updates.await.unwrap();
898+
assert_eq!(state_recv.try_recv(), Err(TryRecvError::Empty));
899+
}

libwebauthn/src/webauthn.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ use tracing::{debug, error, info, instrument, trace, warn};
66

77
use crate::fido::FidoProtocol;
88
use crate::ops::u2f::{RegisterRequest, SignRequest, UpgradableResponse};
9-
use crate::ops::webauthn::{DowngradableRequest, GetAssertionRequest, GetAssertionResponse};
9+
use crate::ops::webauthn::{
10+
DowngradableRequest, GetAssertionRequest, GetAssertionResponse, UserVerificationRequirement,
11+
};
1012
use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse};
1113
use crate::proto::ctap1::Ctap1;
1214
use crate::proto::ctap2::preflight::ctap2_preflight;
@@ -21,6 +23,11 @@ use crate::UvUpdate;
2123

2224
use pin_uv_auth_token::{user_verification, UsedPinUvAuthToken};
2325

26+
// See W3C webauthn#2337.
27+
fn prf_forces_uv_upgrade(prf_present: bool, uv: UserVerificationRequirement) -> bool {
28+
prf_present && !uv.is_required()
29+
}
30+
2431
macro_rules! handle_errors {
2532
($channel: expr, $resp: expr, $uv_auth_used: expr, $timeout: expr) => {
2633
match $resp {
@@ -73,6 +80,18 @@ where
7380
&mut self,
7481
op: &MakeCredentialRequest,
7582
) -> Result<MakeCredentialResponse, Error> {
83+
let upgraded;
84+
let prf_present = op.extensions.as_ref().is_some_and(|e| e.prf.is_some());
85+
let op = if prf_forces_uv_upgrade(prf_present, op.user_verification) {
86+
debug!("PRF requested: forcing userVerification=required (W3C webauthn#2337)");
87+
upgraded = MakeCredentialRequest {
88+
user_verification: UserVerificationRequirement::Required,
89+
..op.clone()
90+
};
91+
&upgraded
92+
} else {
93+
op
94+
};
7695
trace!(?op, "WebAuthn MakeCredential request");
7796
let protocol = negotiate_protocol(self, op.is_downgradable()).await?;
7897
match protocol {
@@ -86,6 +105,18 @@ where
86105
&mut self,
87106
op: &GetAssertionRequest,
88107
) -> Result<GetAssertionResponse, Error> {
108+
let upgraded;
109+
let prf_present = op.extensions.as_ref().is_some_and(|e| e.prf.is_some());
110+
let op = if prf_forces_uv_upgrade(prf_present, op.user_verification) {
111+
debug!("PRF requested: forcing userVerification=required (W3C webauthn#2337)");
112+
upgraded = GetAssertionRequest {
113+
user_verification: UserVerificationRequirement::Required,
114+
..op.clone()
115+
};
116+
&upgraded
117+
} else {
118+
op
119+
};
89120
trace!(?op, "WebAuthn GetAssertion request");
90121
let protocol = negotiate_protocol(self, op.is_downgradable()).await?;
91122
match protocol {
@@ -299,3 +330,44 @@ async fn negotiate_protocol<C: Channel>(
299330
}
300331
Ok(fido_protocol)
301332
}
333+
334+
#[cfg(test)]
335+
mod tests {
336+
use super::*;
337+
338+
#[test]
339+
fn prf_absent_no_upgrade() {
340+
assert!(!prf_forces_uv_upgrade(
341+
false,
342+
UserVerificationRequirement::Discouraged
343+
));
344+
assert!(!prf_forces_uv_upgrade(
345+
false,
346+
UserVerificationRequirement::Preferred
347+
));
348+
assert!(!prf_forces_uv_upgrade(
349+
false,
350+
UserVerificationRequirement::Required
351+
));
352+
}
353+
354+
#[test]
355+
fn prf_present_upgrades_when_not_required() {
356+
assert!(prf_forces_uv_upgrade(
357+
true,
358+
UserVerificationRequirement::Discouraged
359+
));
360+
assert!(prf_forces_uv_upgrade(
361+
true,
362+
UserVerificationRequirement::Preferred
363+
));
364+
}
365+
366+
#[test]
367+
fn prf_present_no_change_when_already_required() {
368+
assert!(!prf_forces_uv_upgrade(
369+
true,
370+
UserVerificationRequirement::Required
371+
));
372+
}
373+
}

0 commit comments

Comments
 (0)