Skip to content

Commit b294ab6

Browse files
feat(webauthn): support hmac-secret-mc CTAP 2.2 extension (fix #147)
Enables PRF evaluation at MakeCredential time on authenticators that advertise hmac-secret-mc. Falls back gracefully to hmac-secret: true (PRF via GetAssertion) when the extension is unsupported. Wire format and processing reuse the existing GA hmac-secret path.
1 parent b1c3d48 commit b294ab6

9 files changed

Lines changed: 495 additions & 76 deletions

File tree

libwebauthn-tests/tests/prf.rs

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,64 @@ async fn test_webauthn_prf_with_pin_set_forced_pin_protocol_two() {
6767
run_test_battery(&mut channel, true).await;
6868
}
6969

70+
/// The Trussed virtual key advertises `hmac-secret` but not `hmac-secret-mc`.
71+
/// Requesting PRF.eval at create() must therefore degrade gracefully: the
72+
/// credential is still created with `hmac-secret: true` so PRF works via GA,
73+
/// no `hmac-secret-mc` is sent on the wire, and `prf.results` stays None.
74+
#[test(tokio::test)]
75+
async fn test_webauthn_prf_eval_at_create_degrades_when_unsupported() {
76+
let mut device = get_virtual_device();
77+
let mut channel = device.channel().await.unwrap();
78+
let state_recv = channel.get_ux_update_receiver();
79+
// PRF forces UV=required (webauthn#2337); no-PIN device drives PIN setup.
80+
tokio::spawn(handle_updates(
81+
state_recv,
82+
vec![
83+
UvUpdateShim::PinNotSet,
84+
UvUpdateShim::PinRequired,
85+
UvUpdateShim::PresenceRequired,
86+
],
87+
));
88+
89+
let user_id: [u8; 32] = thread_rng().gen();
90+
let challenge: [u8; 32] = thread_rng().gen();
91+
let extensions = MakeCredentialsRequestExtensions {
92+
prf: Some(MakeCredentialPrfInput {
93+
eval: Some(PrfInputValue {
94+
first: vec![9; 32],
95+
second: None,
96+
}),
97+
}),
98+
..Default::default()
99+
};
100+
let req = MakeCredentialRequest {
101+
origin: "example.org".to_owned(),
102+
challenge: Vec::from(challenge),
103+
relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"),
104+
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"),
105+
resident_key: Some(ResidentKeyRequirement::Discouraged),
106+
user_verification: UserVerificationRequirement::Discouraged,
107+
algorithms: vec![Ctap2CredentialType::default()],
108+
exclude: None,
109+
extensions: Some(extensions),
110+
timeout: TIMEOUT,
111+
top_origin: None,
112+
};
113+
114+
let response = channel
115+
.webauthn_make_credential(&req)
116+
.await
117+
.expect("MakeCredential should succeed");
118+
assert_eq!(
119+
response.unsigned_extensions_output.prf,
120+
Some(MakeCredentialPrfOutput {
121+
enabled: Some(true),
122+
results: None,
123+
}),
124+
"device does not advertise hmac-secret-mc; results must stay None"
125+
);
126+
}
127+
70128
enum UvUpdateShim {
71129
PresenceRequired,
72130
PinRequired,
@@ -108,7 +166,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
108166
let challenge: [u8; 32] = thread_rng().gen();
109167

110168
let extensions = MakeCredentialsRequestExtensions {
111-
prf: Some(MakeCredentialPrfInput { _eval: None }),
169+
prf: Some(MakeCredentialPrfInput { eval: None }),
112170
..Default::default()
113171
};
114172

@@ -173,7 +231,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
173231
assert_eq!(
174232
response.unsigned_extensions_output.prf,
175233
Some(MakeCredentialPrfOutput {
176-
enabled: Some(true)
234+
enabled: Some(true),
235+
results: None,
177236
})
178237
);
179238

@@ -615,7 +674,7 @@ async fn test_webauthn_prf_variable_length_input() {
615674
algorithms: vec![Ctap2CredentialType::default()],
616675
exclude: None,
617676
extensions: Some(MakeCredentialsRequestExtensions {
618-
prf: Some(MakeCredentialPrfInput { _eval: None }),
677+
prf: Some(MakeCredentialPrfInput { eval: None }),
619678
..Default::default()
620679
}),
621680
timeout: TIMEOUT,
@@ -767,7 +826,7 @@ async fn test_webauthn_prf_upgrades_uv_at_registration() {
767826
&challenge,
768827
UserVerificationRequirement::Discouraged,
769828
Some(MakeCredentialsRequestExtensions {
770-
prf: Some(MakeCredentialPrfInput { _eval: None }),
829+
prf: Some(MakeCredentialPrfInput { eval: None }),
771830
..Default::default()
772831
}),
773832
);
@@ -779,7 +838,8 @@ async fn test_webauthn_prf_upgrades_uv_at_registration() {
779838
assert_eq!(
780839
response.unsigned_extensions_output.prf,
781840
Some(MakeCredentialPrfOutput {
782-
enabled: Some(true)
841+
enabled: Some(true),
842+
results: None,
783843
})
784844
);
785845

@@ -837,7 +897,7 @@ async fn test_webauthn_prf_upgrades_uv_at_assertion() {
837897
&challenge,
838898
UserVerificationRequirement::Required,
839899
Some(MakeCredentialsRequestExtensions {
840-
prf: Some(MakeCredentialPrfInput { _eval: None }),
900+
prf: Some(MakeCredentialPrfInput { eval: None }),
841901
..Default::default()
842902
}),
843903
);

libwebauthn/examples/features/webauthn_prf_hid.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
3535
let challenge: [u8; 32] = thread_rng().gen();
3636

3737
let extensions = MakeCredentialsRequestExtensions {
38-
prf: Some(MakeCredentialPrfInput { _eval: None }),
38+
prf: Some(MakeCredentialPrfInput { eval: None }),
3939
..Default::default()
4040
};
4141

libwebauthn/src/ops/u2f.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ impl UpgradableResponse<MakeCredentialResponse, MakeCredentialRequest> for Regis
168168
enterprise_attestation: None,
169169
large_blob_key: None,
170170
};
171-
Ok(resp.into_make_credential_output(request, None))
171+
Ok(resp.into_make_credential_output(request, None, None))
172172
}
173173
}
174174

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ pub struct PrfOutputValue {
5757
pub second: Option<[u8; 32]>,
5858
}
5959

60+
impl PrfInputValue {
61+
/// WebAuthn L3 PRF: salt = SHA-256("WebAuthn PRF" || 0x00 || ev.{first,second}).
62+
pub fn to_hmac_secret_input(&self) -> HMACGetSecretInput {
63+
const PREFIX: &[u8] = b"WebAuthn PRF\x00";
64+
let hash = |slice: &[u8]| {
65+
let mut hasher = Sha256::default();
66+
hasher.update(PREFIX);
67+
hasher.update(slice);
68+
let mut out = [0u8; 32];
69+
out.copy_from_slice(&hasher.finalize()[..32]);
70+
out
71+
};
72+
HMACGetSecretInput {
73+
salt1: hash(&self.first),
74+
salt2: self.second.as_deref().map(hash),
75+
}
76+
}
77+
}
78+
6079
#[derive(Debug, Clone, PartialEq)]
6180
pub struct GetAssertionRequest {
6281
pub relying_party_id: String,

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
use std::time::Duration;
22

33
use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy;
4-
use serde::{Deserialize, Serialize};
5-
use serde_json::{self, Value as JsonValue};
4+
use serde::{Deserialize, Deserializer, Serialize};
65
use sha2::{Digest, Sha256};
76
use tracing::{debug, instrument, trace};
87

@@ -12,16 +11,17 @@ use crate::{
1211
client_data::ClientData,
1312
idl::{
1413
create::PublicKeyCredentialCreationOptionsJSON,
14+
get::PrfValuesJson,
1515
origin::is_registrable_domain_suffix_or_equal,
1616
response::{
1717
AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON,
18-
CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON,
18+
CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON,
1919
RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse,
2020
},
2121
Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL,
2222
},
2323
psl::PublicSuffixList,
24-
Operation, RelyingPartyId, RequestOrigin,
24+
Operation, PrfInputValue, PrfOutputValue, RelyingPartyId, RequestOrigin,
2525
},
2626
proto::{
2727
ctap1::{Ctap1RegisteredKey, Ctap1Version},
@@ -32,6 +32,7 @@ use crate::{
3232
Ctap2PublicKeyCredentialUserEntity,
3333
},
3434
},
35+
transport::AuthTokenData,
3536
};
3637

3738
use super::timeout::DEFAULT_TIMEOUT;
@@ -176,11 +177,16 @@ impl MakeCredentialResponse {
176177
});
177178
}
178179

179-
// PRF extension
180180
if let Some(prf) = &unsigned_ext.prf {
181181
results.prf = Some(PRFOutputJSON {
182182
enabled: prf.enabled,
183-
results: None,
183+
results: prf.results.as_ref().map(|v| PRFValuesJSON {
184+
first: Base64UrlString::from(v.first.as_slice()),
185+
second: v
186+
.second
187+
.as_ref()
188+
.map(|s| Base64UrlString::from(s.as_slice())),
189+
}),
184190
});
185191
}
186192

@@ -216,19 +222,31 @@ impl MakeCredentialsResponseUnsignedExtensions {
216222
signed_extensions: &Option<Ctap2MakeCredentialsResponseExtensions>,
217223
request: &MakeCredentialRequest,
218224
info: Option<&Ctap2GetInfoResponse>,
225+
auth_data: Option<&AuthTokenData>,
219226
) -> MakeCredentialsResponseUnsignedExtensions {
220227
let mut hmac_create_secret = None;
221228
let mut prf = None;
222229
if let Some(signed_extensions) = signed_extensions {
223230
if let Some(incoming_ext) = &request.extensions {
224-
// hmacCreateSecret and prf can both be requested and returned independently.
225-
// Both map to the same underlying CTAP2 hmac-secret extension.
226231
if incoming_ext.hmac_create_secret.is_some() {
227232
hmac_create_secret = signed_extensions.hmac_secret;
228233
}
229234
if incoming_ext.prf.is_some() {
235+
let results = signed_extensions
236+
.hmac_secret_mc
237+
.as_ref()
238+
.zip(auth_data)
239+
.and_then(|(out, auth)| {
240+
let uv_proto = auth.protocol_version.create_protocol_object();
241+
out.decrypt_output(&auth.shared_secret, uv_proto.as_ref())
242+
})
243+
.map(|decrypted| PrfOutputValue {
244+
first: decrypted.output1,
245+
second: decrypted.output2,
246+
});
230247
prf = Some(MakeCredentialPrfOutput {
231248
enabled: signed_extensions.hmac_secret,
249+
results,
232250
});
233251
}
234252
}
@@ -448,19 +466,32 @@ impl WebAuthnIDL<MakeCredentialRequestParsingError> for MakeCredentialRequest {
448466
type IdlModel = PublicKeyCredentialCreationOptionsJSON;
449467
}
450468

451-
#[derive(Debug, Clone, Deserialize, PartialEq)]
469+
#[derive(Debug, Default, Clone, Deserialize, PartialEq)]
452470
pub struct MakeCredentialPrfInput {
453-
/// The `eval` field is parsed but not used during credential creation.
454-
/// PRF evaluation only occurs during assertion (getAssertion), not registration.
455-
/// We parse it here to accept valid WebAuthn JSON input without errors.
456-
#[serde(rename = "eval")]
457-
pub _eval: Option<JsonValue>,
471+
#[serde(default, deserialize_with = "deserialize_prf_eval")]
472+
pub eval: Option<PrfInputValue>,
473+
}
474+
475+
fn deserialize_prf_eval<'de, D>(deserializer: D) -> Result<Option<PrfInputValue>, D::Error>
476+
where
477+
D: Deserializer<'de>,
478+
{
479+
let Some(json) = Option::<PrfValuesJson>::deserialize(deserializer)? else {
480+
return Ok(None);
481+
};
482+
// WebAuthn L3 §10.1.4: PRF salt inputs are BufferSources of any length.
483+
Ok(Some(PrfInputValue {
484+
first: json.first.as_slice().to_vec(),
485+
second: json.second.map(|s| s.as_slice().to_vec()),
486+
}))
458487
}
459488

460489
#[derive(Debug, Default, Clone, Serialize, PartialEq)]
461490
pub struct MakeCredentialPrfOutput {
462491
#[serde(skip_serializing_if = "Option::is_none")]
463492
pub enabled: Option<bool>,
493+
#[serde(skip_serializing_if = "Option::is_none")]
494+
pub results: Option<PrfOutputValue>,
464495
}
465496

466497
#[derive(Debug, Clone, Deserialize, PartialEq)]
@@ -798,19 +829,55 @@ mod tests {
798829
#[test]
799830
fn test_request_from_json_prf_extension() {
800831
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
801-
let req_json = json_field_add(
802-
REQUEST_BASE_JSON,
803-
"extensions",
804-
r#"{"prf": {"eval": {"first": "second"}}}"#,
805-
);
832+
let first = base64_url::encode(&[1u8; 32]);
833+
let second = base64_url::encode(&[2u8; 32]);
834+
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#);
835+
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);
806836

807837
let req: MakeCredentialRequest =
808838
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
809839
.unwrap();
810-
assert!(matches!(
811-
req.extensions,
812-
Some(MakeCredentialsRequestExtensions { prf: Some(_), .. })
813-
));
840+
let prf = req
841+
.extensions
842+
.as_ref()
843+
.and_then(|e| e.prf.as_ref())
844+
.and_then(|p| p.eval.as_ref())
845+
.expect("prf.eval parsed");
846+
assert_eq!(prf.first, vec![1u8; 32]);
847+
assert_eq!(prf.second, Some(vec![2u8; 32]));
848+
}
849+
850+
#[test]
851+
fn test_request_from_json_prf_extension_empty() {
852+
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
853+
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#);
854+
855+
let req: MakeCredentialRequest =
856+
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
857+
.unwrap();
858+
let prf = req.extensions.unwrap().prf.unwrap();
859+
assert!(prf.eval.is_none());
860+
}
861+
862+
#[test]
863+
fn test_request_from_json_prf_extension_short_input() {
864+
// WebAuthn L3 §10.1.4: PRF salt inputs are BufferSources of any length.
865+
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
866+
let short = base64_url::encode(&[0u8; 16]);
867+
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#);
868+
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);
869+
870+
let req: MakeCredentialRequest =
871+
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
872+
.unwrap();
873+
let prf = req
874+
.extensions
875+
.as_ref()
876+
.and_then(|e| e.prf.as_ref())
877+
.and_then(|p| p.eval.as_ref())
878+
.expect("prf.eval parsed");
879+
assert_eq!(prf.first, vec![0u8; 16]);
880+
assert!(prf.second.is_none());
814881
}
815882

816883
#[test]
@@ -1131,6 +1198,7 @@ mod tests {
11311198
large_blob: None,
11321199
prf: Some(MakeCredentialPrfOutput {
11331200
enabled: Some(true),
1201+
results: None,
11341202
}),
11351203
};
11361204

0 commit comments

Comments
 (0)