Skip to content

Commit f80515d

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 927b548 commit f80515d

9 files changed

Lines changed: 490 additions & 71 deletions

File tree

libwebauthn-tests/tests/prf.rs

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,59 @@ 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+
tokio::spawn(handle_updates(
80+
state_recv,
81+
vec![UvUpdateShim::PresenceRequired],
82+
));
83+
84+
let user_id: [u8; 32] = thread_rng().gen();
85+
let challenge: [u8; 32] = thread_rng().gen();
86+
let extensions = MakeCredentialsRequestExtensions {
87+
prf: Some(MakeCredentialPrfInput {
88+
eval: Some(PRFValue {
89+
first: [9; 32],
90+
second: None,
91+
}),
92+
}),
93+
..Default::default()
94+
};
95+
let req = MakeCredentialRequest {
96+
origin: "example.org".to_owned(),
97+
challenge: Vec::from(challenge),
98+
relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"),
99+
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"),
100+
resident_key: Some(ResidentKeyRequirement::Discouraged),
101+
user_verification: UserVerificationRequirement::Discouraged,
102+
algorithms: vec![Ctap2CredentialType::default()],
103+
exclude: None,
104+
extensions: Some(extensions),
105+
timeout: TIMEOUT,
106+
top_origin: None,
107+
};
108+
109+
let response = channel
110+
.webauthn_make_credential(&req)
111+
.await
112+
.expect("MakeCredential should succeed");
113+
assert_eq!(
114+
response.unsigned_extensions_output.prf,
115+
Some(MakeCredentialPrfOutput {
116+
enabled: Some(true),
117+
results: None,
118+
}),
119+
"device does not advertise hmac-secret-mc; results must stay None"
120+
);
121+
}
122+
70123
enum UvUpdateShim {
71124
PresenceRequired,
72125
PinRequired,
@@ -100,7 +153,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
100153
let challenge: [u8; 32] = thread_rng().gen();
101154

102155
let extensions = MakeCredentialsRequestExtensions {
103-
prf: Some(MakeCredentialPrfInput { _eval: None }),
156+
prf: Some(MakeCredentialPrfInput { eval: None }),
104157
..Default::default()
105158
};
106159

@@ -161,7 +214,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
161214
assert_eq!(
162215
response.unsigned_extensions_output.prf,
163216
Some(MakeCredentialPrfOutput {
164-
enabled: Some(true)
217+
enabled: Some(true),
218+
results: None,
165219
})
166220
);
167221

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
@@ -157,7 +157,7 @@ impl UpgradableResponse<MakeCredentialResponse, MakeCredentialRequest> for Regis
157157
enterprise_attestation: None,
158158
large_blob_key: None,
159159
};
160-
Ok(resp.into_make_credential_output(request, None))
160+
Ok(resp.into_make_credential_output(request, None, None))
161161
}
162162
}
163163

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,25 @@ pub struct PRFValue {
4545
pub second: Option<[u8; 32]>,
4646
}
4747

48+
impl PRFValue {
49+
/// WebAuthn L3 PRF: salt = SHA-256("WebAuthn PRF" || 0x00 || ev.{first,second}).
50+
pub fn to_hmac_secret_input(&self) -> HMACGetSecretInput {
51+
const PREFIX: &[u8] = b"WebAuthn PRF\x00";
52+
let hash = |slice: &[u8; 32]| {
53+
let mut hasher = Sha256::default();
54+
hasher.update(PREFIX);
55+
hasher.update(slice);
56+
let mut out = [0u8; 32];
57+
out.copy_from_slice(&hasher.finalize()[..32]);
58+
out
59+
};
60+
HMACGetSecretInput {
61+
salt1: hash(&self.first),
62+
salt2: self.second.as_ref().map(hash),
63+
}
64+
}
65+
}
66+
4867
#[derive(Debug, Clone, PartialEq)]
4968
pub struct GetAssertionRequest {
5069
pub relying_party_id: String,

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 91 additions & 21 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, PRFValue, 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| PRFValue {
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,40 @@ 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<PRFValue>,
473+
}
474+
475+
fn deserialize_prf_eval<'de, D>(deserializer: D) -> Result<Option<PRFValue>, D::Error>
476+
where
477+
D: Deserializer<'de>,
478+
{
479+
let Some(json) = Option::<PrfValuesJson>::deserialize(deserializer)? else {
480+
return Ok(None);
481+
};
482+
let first: [u8; 32] = json.first.as_slice().try_into().map_err(|_| {
483+
serde::de::Error::invalid_length(
484+
json.first.as_slice().len(),
485+
&"32 bytes (base64url-decoded)",
486+
)
487+
})?;
488+
let second = match json.second {
489+
Some(s) => Some(s.as_slice().try_into().map_err(|_| {
490+
serde::de::Error::invalid_length(s.as_slice().len(), &"32 bytes (base64url-decoded)")
491+
})?),
492+
None => None,
493+
};
494+
Ok(Some(PRFValue { first, second }))
458495
}
459496

460497
#[derive(Debug, Default, Clone, Serialize, PartialEq)]
461498
pub struct MakeCredentialPrfOutput {
462499
#[serde(skip_serializing_if = "Option::is_none")]
463500
pub enabled: Option<bool>,
501+
#[serde(skip_serializing_if = "Option::is_none")]
502+
pub results: Option<PRFValue>,
464503
}
465504

466505
#[derive(Debug, Clone, Deserialize, PartialEq)]
@@ -772,18 +811,48 @@ mod tests {
772811
#[test]
773812
fn test_request_from_json_prf_extension() {
774813
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
775-
let req_json = json_field_add(
776-
REQUEST_BASE_JSON,
777-
"extensions",
778-
r#"{"prf": {"eval": {"first": "second"}}}"#,
779-
);
814+
let first = base64_url::encode(&[1u8; 32]);
815+
let second = base64_url::encode(&[2u8; 32]);
816+
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#);
817+
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);
780818

781819
let req: MakeCredentialRequest =
782820
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
783821
.unwrap();
822+
let prf = req
823+
.extensions
824+
.as_ref()
825+
.and_then(|e| e.prf.as_ref())
826+
.and_then(|p| p.eval.as_ref())
827+
.expect("prf.eval parsed");
828+
assert_eq!(prf.first, [1u8; 32]);
829+
assert_eq!(prf.second, Some([2u8; 32]));
830+
}
831+
832+
#[test]
833+
fn test_request_from_json_prf_extension_empty() {
834+
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
835+
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#);
836+
837+
let req: MakeCredentialRequest =
838+
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
839+
.unwrap();
840+
let prf = req.extensions.unwrap().prf.unwrap();
841+
assert!(prf.eval.is_none());
842+
}
843+
844+
#[test]
845+
fn test_request_from_json_prf_extension_invalid_length() {
846+
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
847+
let short = base64_url::encode(&[0u8; 16]);
848+
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#);
849+
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);
850+
851+
let res =
852+
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
784853
assert!(matches!(
785-
req.extensions,
786-
Some(MakeCredentialsRequestExtensions { prf: Some(_), .. })
854+
res,
855+
Err(MakeCredentialRequestParsingError::EncodingError(_))
787856
));
788857
}
789858

@@ -1105,6 +1174,7 @@ mod tests {
11051174
large_blob: None,
11061175
prf: Some(MakeCredentialPrfOutput {
11071176
enabled: Some(true),
1177+
results: None,
11081178
}),
11091179
};
11101180

libwebauthn/src/proto/ctap2/model/get_assertion.rs

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ use cosey::PublicKey;
2121
use serde::{Deserialize, Serialize};
2222
use serde_bytes::ByteBuf;
2323
use serde_indexed::{DeserializeIndexed, SerializeIndexed};
24-
use sha2::{Digest, Sha256};
2524
use std::collections::{BTreeMap, HashMap};
2625
use tracing::error;
2726

@@ -319,39 +318,8 @@ impl Ctap2GetAssertionRequestExtensions {
319318
ev = eval.as_ref();
320319
}
321320

322-
// 5. If ev is not null:
323-
if let Some(ev) = ev {
324-
// SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first).
325-
let mut prefix = String::from("WebAuthn PRF").into_bytes();
326-
prefix.push(0x00);
327-
328-
let mut input = HMACGetSecretInput::default();
329-
// 5.1 Let salt1 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first).
330-
let mut salt1_input = prefix.clone();
331-
salt1_input.extend(ev.first);
332-
333-
let mut hasher = Sha256::default();
334-
hasher.update(salt1_input);
335-
let salt1_hash = hasher.finalize().to_vec();
336-
input.salt1.copy_from_slice(&salt1_hash[..32]);
337-
338-
// 5.2 If ev.second is present, let salt2 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.second).
339-
if let Some(second) = ev.second {
340-
let mut salt2_input = prefix.clone();
341-
salt2_input.extend(second);
342-
let mut hasher = Sha256::default();
343-
hasher.update(salt2_input);
344-
let salt2_hash = hasher.finalize().to_vec();
345-
let mut salt2 = [0u8; 32];
346-
salt2.copy_from_slice(&salt2_hash[..32]);
347-
input.salt2 = Some(salt2);
348-
};
349-
350-
Ok(Some(input))
351-
} else {
352-
// We don't have a usable PRF, so we don't do any HMAC
353-
Ok(None)
354-
}
321+
// 5. If ev is not null, derive salt1/salt2 per WebAuthn L3.
322+
Ok(ev.map(PRFValue::to_hmac_secret_input))
355323
}
356324
}
357325

libwebauthn/src/proto/ctap2/model/get_info.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ impl Ctap2GetInfoResponse {
178178
self.versions.iter().any(|v| v == "FIDO_2_1")
179179
}
180180

181+
pub fn supports_extension(&self, name: &str) -> bool {
182+
self.extensions
183+
.as_ref()
184+
.is_some_and(|exts| exts.iter().any(|e| e == name))
185+
}
186+
181187
pub fn supports_credential_management(&self) -> bool {
182188
self.option_enabled("credMgmt") || self.option_enabled("credentialMgmtPreview")
183189
}

0 commit comments

Comments
 (0)