Skip to content

Commit bc9799f

Browse files
test(ctap2): expand passthrough prf coverage
1 parent 7883bd0 commit bc9799f

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,32 @@ mod tests {
14591459
);
14601460
}
14611461

1462+
#[test]
1463+
fn prf_output_serialized_into_client_extension_results() {
1464+
let mut response = create_test_response();
1465+
response.unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions {
1466+
prf: Some(MakeCredentialPrfOutput {
1467+
enabled: Some(true),
1468+
results: Some(PrfOutputValue {
1469+
first: [0xAB; 32],
1470+
second: Some([0xCD; 32]),
1471+
}),
1472+
}),
1473+
..Default::default()
1474+
};
1475+
1476+
let results = serde_json::to_value(response.build_client_extension_results()).unwrap();
1477+
assert_eq!(results["prf"]["enabled"], serde_json::json!(true));
1478+
assert_eq!(
1479+
results["prf"]["results"]["first"],
1480+
serde_json::json!(base64_url::encode(&[0xAB; 32]))
1481+
);
1482+
assert_eq!(
1483+
results["prf"]["results"]["second"],
1484+
serde_json::json!(base64_url::encode(&[0xCD; 32]))
1485+
);
1486+
}
1487+
14621488
#[test]
14631489
fn test_response_to_idl_model() {
14641490
let response = create_test_response();

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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,6 +1040,104 @@ mod tests {
10401040
assert!(ext.skip_serializing());
10411041
}
10421042

1043+
#[test]
1044+
fn native_prf_request_serializes_extensions_at_0x04() {
1045+
// Regression guard for the original bug: the prf input used to vanish
1046+
// from the serialized request entirely.
1047+
let request = prf_request(
1048+
vec![],
1049+
Some(PrfInputValue {
1050+
first: b"input".to_vec(),
1051+
second: None,
1052+
}),
1053+
HashMap::new(),
1054+
);
1055+
let info = info_with_extensions(&["prf"]);
1056+
let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap();
1057+
1058+
let bytes = crate::proto::ctap2::cbor::to_vec(&ctap2).unwrap();
1059+
let parsed: BTreeMap<u64, Value> = crate::proto::ctap2::cbor::from_slice(&bytes).unwrap();
1060+
let Some(Value::Map(extensions)) = parsed.get(&0x04) else {
1061+
panic!("extensions (0x04) missing from the wire")
1062+
};
1063+
assert!(extensions.contains_key(&Value::Text("prf".to_string())));
1064+
}
1065+
1066+
#[test]
1067+
fn native_prf_composes_with_large_blob_write() {
1068+
let mut request = prf_request(
1069+
vec![make_credential(b"cred-1")],
1070+
Some(PrfInputValue {
1071+
first: b"input".to_vec(),
1072+
second: None,
1073+
}),
1074+
HashMap::new(),
1075+
);
1076+
request.extensions.as_mut().unwrap().large_blob =
1077+
Some(GetAssertionLargeBlobExtension::Write(b"blob".to_vec()));
1078+
let info = Ctap2GetInfoResponse {
1079+
extensions: Some(vec!["prf".to_string()]),
1080+
options: Some(
1081+
[("largeBlobs".to_string(), true), ("uv".to_string(), true)]
1082+
.into_iter()
1083+
.collect(),
1084+
),
1085+
..Default::default()
1086+
};
1087+
1088+
let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap();
1089+
let ext = ctap2.extensions.as_ref().unwrap();
1090+
assert!(ext.prf.is_some());
1091+
assert!(ext.hmac_or_prf.is_none());
1092+
assert_eq!(ext.large_blob_key, Some(true));
1093+
// The pinUvAuthToken is still negotiated for the lbw permission.
1094+
assert!(ctap2.needs_pin_uv_auth_token(&info));
1095+
assert!(ctap2.needs_shared_secret(&info));
1096+
}
1097+
1098+
#[test]
1099+
fn native_prf_forwards_all_matching_eval_by_credential_entries() {
1100+
let mut by_cred = HashMap::new();
1101+
for (id, salt) in [
1102+
(&b"cred-1"[..], &b"salt-1"[..]),
1103+
(b"cred-2", b"salt-2"),
1104+
(b"unknown-cred", b"salt-3"),
1105+
] {
1106+
by_cred.insert(
1107+
base64_url::encode(id),
1108+
PrfInputValue {
1109+
first: salt.to_vec(),
1110+
second: None,
1111+
},
1112+
);
1113+
}
1114+
let request = prf_request(
1115+
vec![make_credential(b"cred-1"), make_credential(b"cred-2")],
1116+
None,
1117+
by_cred,
1118+
);
1119+
let info = info_with_extensions(&["prf"]);
1120+
1121+
let ctap2 = Ctap2GetAssertionRequest::from_webauthn_request(&request, &info).unwrap();
1122+
let prf = ctap2.extensions.as_ref().unwrap().prf.as_ref().unwrap();
1123+
let by_cred = prf.eval_by_credential.as_ref().unwrap();
1124+
assert_eq!(by_cred.len(), 2);
1125+
assert_eq!(
1126+
by_cred
1127+
.get(&ByteBuf::from(b"cred-1".to_vec()))
1128+
.unwrap()
1129+
.first,
1130+
hashed_salt(b"salt-1")
1131+
);
1132+
assert_eq!(
1133+
by_cred
1134+
.get(&ByteBuf::from(b"cred-2".to_vec()))
1135+
.unwrap()
1136+
.first,
1137+
hashed_salt(b"salt-2")
1138+
);
1139+
}
1140+
10431141
#[test]
10441142
fn native_prf_invalid_eval_by_credential_keys_are_syntax_errors() {
10451143
let info = info_with_extensions(&["prf"]);
@@ -1195,6 +1293,62 @@ mod tests {
11951293
let parsed =
11961294
parse_unsigned_prf(&unsigned_prf_outputs(&[0xAB; 32], Some(&[0xCD; 16]))).unwrap();
11971295
assert!(parsed.results.is_none());
1296+
1297+
// Non-bool enabled is ignored
1298+
let mut prf = BTreeMap::new();
1299+
prf.insert(Value::Text("enabled".to_string()), Value::Integer(1));
1300+
let mut outputs = BTreeMap::new();
1301+
outputs.insert(Value::Text("prf".to_string()), Value::Map(prf));
1302+
let parsed = parse_unsigned_prf(&outputs).unwrap();
1303+
assert!(parsed.enabled.is_none());
1304+
}
1305+
1306+
#[test]
1307+
fn surfaces_passthrough_prf_results_in_client_extension_results() {
1308+
use crate::ops::webauthn::idl::response::{JsonFormat, WebAuthnIDLResponse};
1309+
1310+
// End-to-end GPM shape: results only in unsignedExtensionOutputs (0x08),
1311+
// no signed extensions, ED flag unset.
1312+
let mut auth_data = vec![0u8; 37];
1313+
auth_data[32] = AuthenticatorDataFlags::USER_PRESENT.bits();
1314+
1315+
let mut response: BTreeMap<u64, Value> = BTreeMap::new();
1316+
response.insert(0x02, Value::Bytes(auth_data));
1317+
response.insert(0x03, Value::Bytes(vec![0xAAu8; 64]));
1318+
response.insert(
1319+
0x08,
1320+
Value::Map(unsigned_prf_outputs(&[0xAB; 32], Some(&[0xCD; 32]))),
1321+
);
1322+
1323+
let bytes = crate::proto::ctap2::cbor::to_vec(&response).unwrap();
1324+
let parsed: Ctap2GetAssertionResponse =
1325+
crate::proto::ctap2::cbor::from_slice(&bytes).unwrap();
1326+
1327+
let request = prf_request(
1328+
vec![make_credential(b"cred-1")],
1329+
Some(PrfInputValue {
1330+
first: b"input".to_vec(),
1331+
second: None,
1332+
}),
1333+
HashMap::new(),
1334+
);
1335+
let assertion = parsed.into_assertion_output(&request, None);
1336+
let json_str = assertion
1337+
.to_json_string(&request, JsonFormat::default())
1338+
.unwrap();
1339+
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1340+
1341+
let prf = &json["clientExtensionResults"]["prf"];
1342+
assert_eq!(
1343+
prf["results"]["first"],
1344+
serde_json::json!(base64_url::encode(&[0xAB; 32]))
1345+
);
1346+
assert_eq!(
1347+
prf["results"]["second"],
1348+
serde_json::json!(base64_url::encode(&[0xCD; 32]))
1349+
);
1350+
// Exactly one prf member: the raw passthrough must not emit a duplicate.
1351+
assert_eq!(json_str.matches("\"prf\"").count(), 1);
11981352
}
11991353

12001354
#[test]

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,77 @@ mod tests {
728728
assert!(prf.results.is_none());
729729
}
730730

731+
#[test]
732+
fn native_prf_request_serializes_extensions_at_0x06() {
733+
let info = info_with_extensions(&["prf"]);
734+
let req = mc_request_with_prf(Some(PrfInputValue {
735+
first: b"input".to_vec(),
736+
second: None,
737+
}));
738+
let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap();
739+
740+
let bytes = crate::proto::ctap2::cbor::to_vec(&ctap).unwrap();
741+
let parsed: BTreeMap<u64, Value> = crate::proto::ctap2::cbor::from_slice(&bytes).unwrap();
742+
let Some(Value::Map(extensions)) = parsed.get(&0x06) else {
743+
panic!("extensions (0x06) missing from the wire")
744+
};
745+
assert!(extensions.contains_key(&Value::Text("prf".to_string())));
746+
}
747+
748+
#[test]
749+
fn hmac_create_secret_not_rerouted_by_prf_support() {
750+
// The passthrough applies to the prf extension only.
751+
let info = info_with_extensions(&["prf"]);
752+
let req = MakeCredentialRequest {
753+
extensions: Some(MakeCredentialsRequestExtensions {
754+
hmac_create_secret: Some(true),
755+
..Default::default()
756+
}),
757+
..mc_request_with_prf(None)
758+
};
759+
let ctap = Ctap2MakeCredentialRequest::from_webauthn_request(&req, &info).unwrap();
760+
let ext = ctap.extensions.unwrap();
761+
assert!(ext.prf.is_none());
762+
assert_eq!(ext.hmac_secret, Some(true));
763+
}
764+
765+
#[test]
766+
fn prf_create_time_results_parsed_from_unsigned_extension_outputs() {
767+
// Google Password Manager phones evaluate eval at creation and return
768+
// results alongside enabled.
769+
let mut results = BTreeMap::new();
770+
results.insert(
771+
Value::Text("first".to_string()),
772+
Value::Bytes(vec![0xAB; 32]),
773+
);
774+
results.insert(
775+
Value::Text("second".to_string()),
776+
Value::Bytes(vec![0xCD; 32]),
777+
);
778+
let mut prf_entry = BTreeMap::new();
779+
prf_entry.insert(Value::Text("enabled".to_string()), Value::Bool(true));
780+
prf_entry.insert(Value::Text("results".to_string()), Value::Map(results));
781+
let mut outputs = BTreeMap::new();
782+
outputs.insert(Value::Text("prf".to_string()), Value::Map(prf_entry));
783+
784+
let req = mc_request_with_prf(Some(PrfInputValue {
785+
first: b"input".to_vec(),
786+
second: Some(b"input-2".to_vec()),
787+
}));
788+
let out = MakeCredentialsResponseUnsignedExtensions::from_signed_extensions(
789+
&None,
790+
Some(&outputs),
791+
&req,
792+
None,
793+
None,
794+
);
795+
let prf = out.prf.expect("prf output present");
796+
assert_eq!(prf.enabled, Some(true));
797+
let results = prf.results.expect("create-time results present");
798+
assert_eq!(results.first, [0xAB; 32]);
799+
assert_eq!(results.second, Some([0xCD; 32]));
800+
}
801+
731802
#[test]
732803
fn decodes_unsigned_extension_outputs_at_index_0x06() {
733804
// 0x06 is unsignedExtensionOutputs (CTAP 2.2 §6.1).

0 commit comments

Comments
 (0)