Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions libwebauthn-tests/tests/prf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,59 @@ async fn test_webauthn_prf_with_pin_set_forced_pin_protocol_two() {
run_test_battery(&mut channel, true).await;
}

/// The Trussed virtual key advertises `hmac-secret` but not `hmac-secret-mc`.
/// Requesting PRF.eval at create() must therefore degrade gracefully: the
/// credential is still created with `hmac-secret: true` so PRF works via GA,
/// no `hmac-secret-mc` is sent on the wire, and `prf.results` stays None.
#[test(tokio::test)]
async fn test_webauthn_prf_eval_at_create_degrades_when_unsupported() {
let mut device = get_virtual_device();
let mut channel = device.channel().await.unwrap();
let state_recv = channel.get_ux_update_receiver();
tokio::spawn(handle_updates(
state_recv,
vec![UvUpdateShim::PresenceRequired],
));

let user_id: [u8; 32] = thread_rng().gen();
let challenge: [u8; 32] = thread_rng().gen();
let extensions = MakeCredentialsRequestExtensions {
prf: Some(MakeCredentialPrfInput {
eval: Some(PRFValue {
first: [9; 32],
second: None,
}),
}),
..Default::default()
};
let req = MakeCredentialRequest {
origin: "example.org".to_owned(),
challenge: Vec::from(challenge),
relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"),
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"),
resident_key: Some(ResidentKeyRequirement::Discouraged),
user_verification: UserVerificationRequirement::Discouraged,
algorithms: vec![Ctap2CredentialType::default()],
exclude: None,
extensions: Some(extensions),
timeout: TIMEOUT,
top_origin: None,
};

let response = channel
.webauthn_make_credential(&req)
.await
.expect("MakeCredential should succeed");
assert_eq!(
response.unsigned_extensions_output.prf,
Some(MakeCredentialPrfOutput {
enabled: Some(true),
results: None,
}),
"device does not advertise hmac-secret-mc; results must stay None"
);
}

enum UvUpdateShim {
PresenceRequired,
PinRequired,
Expand Down Expand Up @@ -100,7 +153,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
let challenge: [u8; 32] = thread_rng().gen();

let extensions = MakeCredentialsRequestExtensions {
prf: Some(MakeCredentialPrfInput { _eval: None }),
prf: Some(MakeCredentialPrfInput { eval: None }),
..Default::default()
};

Expand Down Expand Up @@ -161,7 +214,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
assert_eq!(
response.unsigned_extensions_output.prf,
Some(MakeCredentialPrfOutput {
enabled: Some(true)
enabled: Some(true),
results: None,
})
);

Expand Down
2 changes: 1 addition & 1 deletion libwebauthn/examples/features/webauthn_prf_hid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
let challenge: [u8; 32] = thread_rng().gen();

let extensions = MakeCredentialsRequestExtensions {
prf: Some(MakeCredentialPrfInput { _eval: None }),
prf: Some(MakeCredentialPrfInput { eval: None }),
..Default::default()
};

Expand Down
2 changes: 1 addition & 1 deletion libwebauthn/src/ops/u2f.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ impl UpgradableResponse<MakeCredentialResponse, MakeCredentialRequest> for Regis
enterprise_attestation: None,
large_blob_key: None,
};
Ok(resp.into_make_credential_output(request, None))
Ok(resp.into_make_credential_output(request, None, None))
}
}

Expand Down
19 changes: 19 additions & 0 deletions libwebauthn/src/ops/webauthn/get_assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,25 @@ pub struct PRFValue {
pub second: Option<[u8; 32]>,
}

impl PRFValue {
/// WebAuthn L3 PRF: salt = SHA-256("WebAuthn PRF" || 0x00 || ev.{first,second}).
pub fn to_hmac_secret_input(&self) -> HMACGetSecretInput {
const PREFIX: &[u8] = b"WebAuthn PRF\x00";
let hash = |slice: &[u8; 32]| {
let mut hasher = Sha256::default();
hasher.update(PREFIX);
hasher.update(slice);
let mut out = [0u8; 32];
out.copy_from_slice(&hasher.finalize()[..32]);
out
};
HMACGetSecretInput {
salt1: hash(&self.first),
salt2: self.second.as_ref().map(hash),
}
}
}

#[derive(Debug, Clone, PartialEq)]
pub struct GetAssertionRequest {
pub relying_party_id: String,
Expand Down
112 changes: 91 additions & 21 deletions libwebauthn/src/ops/webauthn/make_credential.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
use std::time::Duration;

use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy;
use serde::{Deserialize, Serialize};
use serde_json::{self, Value as JsonValue};
use serde::{Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
use tracing::{debug, instrument, trace};

Expand All @@ -12,16 +11,17 @@ use crate::{
client_data::ClientData,
idl::{
create::PublicKeyCredentialCreationOptionsJSON,
get::PrfValuesJson,
origin::is_registrable_domain_suffix_or_equal,
response::{
AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON,
CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON,
CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON,
RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse,
},
Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL,
},
psl::PublicSuffixList,
Operation, RelyingPartyId, RequestOrigin,
Operation, PRFValue, RelyingPartyId, RequestOrigin,
},
proto::{
ctap1::{Ctap1RegisteredKey, Ctap1Version},
Expand All @@ -32,6 +32,7 @@ use crate::{
Ctap2PublicKeyCredentialUserEntity,
},
},
transport::AuthTokenData,
};

use super::timeout::DEFAULT_TIMEOUT;
Expand Down Expand Up @@ -176,11 +177,16 @@ impl MakeCredentialResponse {
});
}

// PRF extension
if let Some(prf) = &unsigned_ext.prf {
results.prf = Some(PRFOutputJSON {
enabled: prf.enabled,
results: None,
results: prf.results.as_ref().map(|v| PRFValuesJSON {
first: Base64UrlString::from(v.first.as_slice()),
second: v
.second
.as_ref()
.map(|s| Base64UrlString::from(s.as_slice())),
}),
});
}

Expand Down Expand Up @@ -216,19 +222,31 @@ impl MakeCredentialsResponseUnsignedExtensions {
signed_extensions: &Option<Ctap2MakeCredentialsResponseExtensions>,
request: &MakeCredentialRequest,
info: Option<&Ctap2GetInfoResponse>,
auth_data: Option<&AuthTokenData>,
) -> MakeCredentialsResponseUnsignedExtensions {
let mut hmac_create_secret = None;
let mut prf = None;
if let Some(signed_extensions) = signed_extensions {
if let Some(incoming_ext) = &request.extensions {
// hmacCreateSecret and prf can both be requested and returned independently.
// Both map to the same underlying CTAP2 hmac-secret extension.
if incoming_ext.hmac_create_secret.is_some() {
hmac_create_secret = signed_extensions.hmac_secret;
}
if incoming_ext.prf.is_some() {
let results = signed_extensions
.hmac_secret_mc
.as_ref()
.zip(auth_data)
.and_then(|(out, auth)| {
let uv_proto = auth.protocol_version.create_protocol_object();
out.decrypt_output(&auth.shared_secret, uv_proto.as_ref())
})
.map(|decrypted| PRFValue {
first: decrypted.output1,
second: decrypted.output2,
});
prf = Some(MakeCredentialPrfOutput {
enabled: signed_extensions.hmac_secret,
results,
});
}
}
Expand Down Expand Up @@ -448,19 +466,40 @@ impl WebAuthnIDL<MakeCredentialRequestParsingError> for MakeCredentialRequest {
type IdlModel = PublicKeyCredentialCreationOptionsJSON;
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
#[derive(Debug, Default, Clone, Deserialize, PartialEq)]
pub struct MakeCredentialPrfInput {
/// The `eval` field is parsed but not used during credential creation.
/// PRF evaluation only occurs during assertion (getAssertion), not registration.
/// We parse it here to accept valid WebAuthn JSON input without errors.
#[serde(rename = "eval")]
pub _eval: Option<JsonValue>,
#[serde(default, deserialize_with = "deserialize_prf_eval")]
pub eval: Option<PRFValue>,
}

fn deserialize_prf_eval<'de, D>(deserializer: D) -> Result<Option<PRFValue>, D::Error>
where
D: Deserializer<'de>,
{
let Some(json) = Option::<PrfValuesJson>::deserialize(deserializer)? else {
return Ok(None);
};
let first: [u8; 32] = json.first.as_slice().try_into().map_err(|_| {
serde::de::Error::invalid_length(
json.first.as_slice().len(),
&"32 bytes (base64url-decoded)",
)
})?;
let second = match json.second {
Some(s) => Some(s.as_slice().try_into().map_err(|_| {
serde::de::Error::invalid_length(s.as_slice().len(), &"32 bytes (base64url-decoded)")
})?),
None => None,
};
Ok(Some(PRFValue { first, second }))
}

#[derive(Debug, Default, Clone, Serialize, PartialEq)]
pub struct MakeCredentialPrfOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub results: Option<PRFValue>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
Expand Down Expand Up @@ -772,18 +811,48 @@ mod tests {
#[test]
fn test_request_from_json_prf_extension() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
"extensions",
r#"{"prf": {"eval": {"first": "second"}}}"#,
);
let first = base64_url::encode(&[1u8; 32]);
let second = base64_url::encode(&[2u8; 32]);
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#);
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);

let req: MakeCredentialRequest =
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
.unwrap();
let prf = req
.extensions
.as_ref()
.and_then(|e| e.prf.as_ref())
.and_then(|p| p.eval.as_ref())
.expect("prf.eval parsed");
assert_eq!(prf.first, [1u8; 32]);
assert_eq!(prf.second, Some([2u8; 32]));
}

#[test]
fn test_request_from_json_prf_extension_empty() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#);

let req: MakeCredentialRequest =
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
.unwrap();
let prf = req.extensions.unwrap().prf.unwrap();
assert!(prf.eval.is_none());
}

#[test]
fn test_request_from_json_prf_extension_invalid_length() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let short = base64_url::encode(&[0u8; 16]);
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#);
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);

let res =
MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
assert!(matches!(
req.extensions,
Some(MakeCredentialsRequestExtensions { prf: Some(_), .. })
res,
Err(MakeCredentialRequestParsingError::EncodingError(_))
));
}

Expand Down Expand Up @@ -1105,6 +1174,7 @@ mod tests {
large_blob: None,
prf: Some(MakeCredentialPrfOutput {
enabled: Some(true),
results: None,
}),
};

Expand Down
36 changes: 2 additions & 34 deletions libwebauthn/src/proto/ctap2/model/get_assertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ use cosey::PublicKey;
use serde::{Deserialize, Serialize};
use serde_bytes::ByteBuf;
use serde_indexed::{DeserializeIndexed, SerializeIndexed};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use tracing::error;

Expand Down Expand Up @@ -319,39 +318,8 @@ impl Ctap2GetAssertionRequestExtensions {
ev = eval.as_ref();
}

// 5. If ev is not null:
if let Some(ev) = ev {
// SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first).
let mut prefix = String::from("WebAuthn PRF").into_bytes();
prefix.push(0x00);

let mut input = HMACGetSecretInput::default();
// 5.1 Let salt1 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.first).
let mut salt1_input = prefix.clone();
salt1_input.extend(ev.first);

let mut hasher = Sha256::default();
hasher.update(salt1_input);
let salt1_hash = hasher.finalize().to_vec();
input.salt1.copy_from_slice(&salt1_hash[..32]);

// 5.2 If ev.second is present, let salt2 be the value of SHA-256(UTF8Encode("WebAuthn PRF") || 0x00 || ev.second).
if let Some(second) = ev.second {
let mut salt2_input = prefix.clone();
salt2_input.extend(second);
let mut hasher = Sha256::default();
hasher.update(salt2_input);
let salt2_hash = hasher.finalize().to_vec();
let mut salt2 = [0u8; 32];
salt2.copy_from_slice(&salt2_hash[..32]);
input.salt2 = Some(salt2);
};

Ok(Some(input))
} else {
// We don't have a usable PRF, so we don't do any HMAC
Ok(None)
}
// 5. If ev is not null, derive salt1/salt2 per WebAuthn L3.
Ok(ev.map(PRFValue::to_hmac_secret_input))
}
}

Expand Down
6 changes: 6 additions & 0 deletions libwebauthn/src/proto/ctap2/model/get_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ impl Ctap2GetInfoResponse {
self.versions.iter().any(|v| v == "FIDO_2_1")
}

pub fn supports_extension(&self, name: &str) -> bool {
self.extensions
.as_ref()
.is_some_and(|exts| exts.iter().any(|e| e == name))
}

pub fn supports_credential_management(&self) -> bool {
self.option_enabled("credMgmt") || self.option_enabled("credentialMgmtPreview")
}
Expand Down
Loading
Loading