diff --git a/libwebauthn-tests/tests/preflight.rs b/libwebauthn-tests/tests/preflight.rs index 3280f4d..da530e5 100644 --- a/libwebauthn-tests/tests/preflight.rs +++ b/libwebauthn-tests/tests/preflight.rs @@ -4,7 +4,7 @@ use libwebauthn::ops::webauthn::{ GetAssertionRequest, GetAssertionResponse, MakeCredentialRequest, ResidentKeyRequirement, UserVerificationRequirement, }; -use libwebauthn::proto::ctap2::preflight::ctap2_preflight; +use libwebauthn::proto::ctap2::preflight::{ctap2_preflight, ctap2_preflight_with_appid}; use libwebauthn::proto::ctap2::{ Ctap2CredentialType, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialType, Ctap2PublicKeyCredentialUserEntity, @@ -36,12 +36,21 @@ async fn make_credential_call( channel: &mut HidChannel<'_>, user_id: &[u8], exclude_list: Option>, +) -> Result<(Ctap2PublicKeyCredentialDescriptor, [u8; 32]), Error> { + make_credential_call_with_rp(channel, user_id, exclude_list, "example.org").await +} + +async fn make_credential_call_with_rp( + channel: &mut HidChannel<'_>, + user_id: &[u8], + exclude_list: Option>, + rp_id: &str, ) -> Result<(Ctap2PublicKeyCredentialDescriptor, [u8; 32]), Error> { let challenge: [u8; 32] = thread_rng().gen(); let make_credentials_request = MakeCredentialRequest { - origin: "example.org".to_owned(), + origin: rp_id.to_owned(), challenge: Vec::from(challenge), - relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), + relying_party: Ctap2PublicKeyCredentialRpEntity::new(rp_id, rp_id), user: Ctap2PublicKeyCredentialUserEntity::new(user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), user_verification: UserVerificationRequirement::Preferred, @@ -239,6 +248,58 @@ async fn preflight_nonsense_allow_list() { .await; } +#[test(tokio::test)] +async fn preflight_with_appid_exclude_finds_legacy_credential() { + // Register a credential under a "legacy" relying party id (standing + // in for a U2F AppID), then run preflight against a different rpId + // while passing the legacy rpId as `appid_exclude`. The credential + // should be detected, matching WebAuthn L3 §10.1.2 semantics. + let mut device = get_virtual_device(); + let mut channel = device.channel().await.unwrap(); + + let user_id: [u8; 32] = thread_rng().gen(); + let _state_recv = channel.get_ux_update_receiver(); + + // First, register the legacy credential. + let (legacy_credential, _) = + make_credential_call_with_rp(&mut channel, &user_id, None, "legacy.example.org") + .await + .expect("Failed to register legacy credential"); + + // Preflight under "example.org" without appid_exclude: legacy + // credential is not detected. + let hash: [u8; 32] = thread_rng().gen(); + let filtered_no_appid = ctap2_preflight_with_appid( + &mut channel, + std::slice::from_ref(&legacy_credential), + &hash, + "example.org", + None, + ) + .await; + assert!( + filtered_no_appid.is_empty(), + "Without appid_exclude, the legacy credential should not be detected" + ); + + // Preflight again, this time providing the legacy rpId as + // appid_exclude. The credential must be detected. + let filtered_with_appid = ctap2_preflight_with_appid( + &mut channel, + std::slice::from_ref(&legacy_credential), + &hash, + "example.org", + Some("legacy.example.org"), + ) + .await; + assert_eq!( + filtered_with_appid.len(), + 1, + "With appid_exclude set, the legacy credential should be detected" + ); + assert_eq!(filtered_with_appid[0].id, legacy_credential.id); +} + #[test(tokio::test)] async fn preflight_mixed_allow_list() { // Get assertion with a mixed allow_list that contains 2 real ones. Should remove the two fake ones in preflight diff --git a/libwebauthn/examples/features/webauthn_extensions_hid.rs b/libwebauthn/examples/features/webauthn_extensions_hid.rs index f3e4d58..853c2c8 100644 --- a/libwebauthn/examples/features/webauthn_extensions_hid.rs +++ b/libwebauthn/examples/features/webauthn_extensions_hid.rs @@ -43,6 +43,7 @@ pub async fn main() -> Result<(), Box> { hmac_create_secret: Some(true), prf: None, cred_props: Some(true), + appid_exclude: None, }; for mut device in devices { diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index df55859..6d3373a 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -96,6 +96,29 @@ pub enum GetAssertionRequestParsingError { #[error("Mismatching relying party ID: {0} != {1}")] MismatchingRelyingPartyId(String, String), + + #[error("Invalid AppID: {0}")] + InvalidAppId(String), +} + +/// Basic sanity check for FIDO AppID strings (WebAuthn L3 §10.1.1). +/// +/// Per spec the AppID should be a same-site URL (typically `https:///...`). +/// Full same-site validation against the rpId is not yet implemented; for now +/// we require non-empty input, an absolute URL form, and the `https` scheme. +fn validate_appid(appid: &str) -> Result { + if appid.is_empty() { + return Err(GetAssertionRequestParsingError::InvalidAppId( + "appid must not be empty".to_string(), + )); + } + // Sanity check: must be an https URL. + if !appid.starts_with("https://") { + return Err(GetAssertionRequestParsingError::InvalidAppId(format!( + "appid must be an https URL, got: {appid}" + ))); + } + Ok(appid.to_string()) } impl WebAuthnIDL for GetAssertionRequest { @@ -145,6 +168,11 @@ impl FromIdlModel None, }; + let appid = match inner.extensions.as_ref().and_then(|e| e.appid.as_ref()) { + Some(s) => Some(validate_appid(s)?), + None => None, + }; + let extensions = inner .extensions @@ -156,6 +184,7 @@ impl FromIdlModel, pub large_blob: Option, + /// FIDO AppID extension (WebAuthn L3 §10.1.1). When the relying party has + /// existing U2F credentials registered under a legacy AppID, this URL is + /// hashed in place of the rpId to derive the U2F application parameter. + pub appid: Option, } #[derive(Clone, Debug, Default, Serialize)] @@ -389,6 +422,12 @@ pub struct GetAssertionResponseUnsignedExtensions { pub large_blob: Option, #[serde(skip_serializing_if = "Option::is_none")] pub prf: Option, + /// FIDO AppID extension output (WebAuthn L3 §10.1.1): + /// `Some(true)` if the assertion matched the legacy AppID-derived application + /// parameter, `Some(false)` if AppID was supplied but `rp.id` was used, `None` + /// if the extension wasn't requested. + #[serde(skip_serializing_if = "Option::is_none")] + pub appid: Option, } /// Context required for serializing a GetAssertion response to JSON. @@ -464,6 +503,9 @@ impl Assertion { let mut results = AuthenticationExtensionsClientOutputsJSON::default(); if let Some(unsigned_ext) = &self.unsigned_extensions_output { + // FIDO AppID extension output + results.appid = unsigned_ext.appid; + // HMAC-secret extension output if let Some(hmac_output) = &unsigned_ext.hmac_get_secret { results.hmac_get_secret = Some(HMACGetSecretOutputJSON { @@ -541,34 +583,62 @@ impl DowngradableRequest> for GetAssertionRequest { fn try_downgrade(&self) -> Result, CtapError> { trace!(?self); - let downgraded_requests: Vec = self - .allow - .iter() - .map(|credential| { - // Let controlByte be a byte initialized as follows: - // * If "up" is set to false, set it to 0x08 (dont-enforce-user-presence-and-sign). - // * For USB, set it to 0x07 (check-only). This should prevent call getting blocked on waiting for user - // input. If response returns success, then call again setting the enforce-user-presence-and-sign. - // * For NFC, set it to 0x03 (enforce-user-presence-and-sign). The tap has already provided the presence - // and won’t block. - // --> This is already set to 0x08 in trait: From<&Ctap1RegisterRequest> for ApduRequest - - // Use clientDataHash parameter of CTAP2 request as CTAP1/U2F challenge parameter (32 bytes). - let challenge = self.client_data_hash(); - - // Let rpIdHash be a byte string of size 32 initialized with SHA-256 hash of rp.id parameter as - // CTAP1/U2F application parameter (32 bytes). + let challenge = self.client_data_hash(); + + // Let rpIdHash be a byte string of size 32 initialized with SHA-256 hash of rp.id parameter as + // CTAP1/U2F application parameter (32 bytes). + let mut hasher = Sha256::default(); + hasher.update(self.relying_party_id.as_bytes()); + let rp_id_hash = hasher.finalize().to_vec(); + + // FIDO AppID extension (WebAuthn L3 §10.1.1): if the relying party + // supplies a legacy AppID, additionally derive a second application + // parameter from `SHA-256(appid)` and emit a paired SignRequest for + // each credential. The downstream U2F sign loop tries both, mirroring + // the spec's "try rpId first, then appID" preflight model. + let appid_hash: Option> = self + .extensions + .as_ref() + .and_then(|e| e.appid.as_ref()) + .map(|appid| { let mut hasher = Sha256::default(); - hasher.update(self.relying_party_id.as_bytes()); - let rp_id_hash = hasher.finalize().to_vec(); - - // Let credentialId is the byte string initialized with the id for this PublicKeyCredentialDescriptor. - let credential_id = &credential.id; - - // Let u2fAuthenticateRequest be a byte string with the following structure: [...] - SignRequest::new_upgraded(&rp_id_hash, &challenge, credential_id, self.timeout) - }) - .collect(); + hasher.update(appid.as_bytes()); + hasher.finalize().to_vec() + }); + + let mut downgraded_requests: Vec = Vec::new(); + for credential in &self.allow { + // Let controlByte be a byte initialized as follows: + // * If "up" is set to false, set it to 0x08 (dont-enforce-user-presence-and-sign). + // * For USB, set it to 0x07 (check-only). This should prevent call getting blocked on waiting for user + // input. If response returns success, then call again setting the enforce-user-presence-and-sign. + // * For NFC, set it to 0x03 (enforce-user-presence-and-sign). The tap has already provided the presence + // and won’t block. + // --> This is already set to 0x08 in trait: From<&Ctap1RegisterRequest> for ApduRequest + + // Let credentialId is the byte string initialized with the id for this PublicKeyCredentialDescriptor. + let credential_id = &credential.id; + + // Let u2fAuthenticateRequest be a byte string with the following structure: [...] + downgraded_requests.push(SignRequest::new_upgraded( + &rp_id_hash, + &challenge, + credential_id, + self.timeout, + )); + + // If an AppID was supplied, also emit a sign request keyed under + // the legacy AppID hash, so a U2F-keyed credential under the old + // application parameter remains reachable. + if let Some(ref appid_hash) = appid_hash { + downgraded_requests.push(SignRequest::new_upgraded( + appid_hash, + &challenge, + credential_id, + self.timeout, + )); + } + } trace!(?downgraded_requests); Ok(downgraded_requests) } @@ -758,6 +828,86 @@ mod tests { ); } + #[test] + fn test_request_from_json_appid_extension() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appid":"https://www.example.org/u2f/origins.json"}"#, + ); + + let req: GetAssertionRequest = + GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); + let ext = req.extensions.expect("extensions should be present"); + assert_eq!( + ext.appid.as_deref(), + Some("https://www.example.org/u2f/origins.json") + ); + } + + #[test] + fn test_request_from_json_appid_extension_invalid_non_https() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appid":"http://www.example.org/u2f/origins.json"}"#, + ); + + let res = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json); + assert!(matches!( + res, + Err(GetAssertionRequestParsingError::InvalidAppId(_)) + )); + } + + #[test] + fn test_try_downgrade_with_appid_uses_appid_hash() { + use sha2::{Digest, Sha256}; + + let mut req = request_base(); + req.extensions = Some(GetAssertionRequestExtensions { + cred_blob: false, + prf: None, + large_blob: None, + appid: Some("https://www.example.org/u2f/origins.json".to_string()), + }); + + let sign_requests = req.try_downgrade().expect("downgrade ok"); + // With one credential in allow and `appid` set, we emit two requests: + // first the rp.id-derived one, then the appid-derived one. + assert_eq!(sign_requests.len(), 2); + + let mut rp_hasher = Sha256::default(); + rp_hasher.update(b"example.org"); + let rp_hash = rp_hasher.finalize().to_vec(); + + let mut appid_hasher = Sha256::default(); + appid_hasher.update(b"https://www.example.org/u2f/origins.json"); + let appid_hash = appid_hasher.finalize().to_vec(); + + assert_eq!(sign_requests[0].app_id_hash, rp_hash); + assert_eq!(sign_requests[1].app_id_hash, appid_hash); + assert_eq!(sign_requests[0].key_handle, sign_requests[1].key_handle); + } + + #[test] + fn test_try_downgrade_without_appid_uses_rp_hash() { + use sha2::{Digest, Sha256}; + + let req = request_base(); + let sign_requests = req.try_downgrade().expect("downgrade ok"); + // One credential, no appid: one request. + assert_eq!(sign_requests.len(), 1); + + let mut rp_hasher = Sha256::default(); + rp_hasher.update(b"example.org"); + let rp_hash = rp_hasher.finalize().to_vec(); + assert_eq!(sign_requests[0].app_id_hash, rp_hash); + } + #[test] #[ignore] // FIXME(#134) allow arbitrary size input fn test_request_from_json_prf_extension() { @@ -909,6 +1059,7 @@ mod tests { second: None, }), }), + appid: None, }); let request = create_test_request(); @@ -921,4 +1072,48 @@ mod tests { assert_eq!(results.first.0, vec![0x01u8; 32]); assert!(results.second.is_none()); } + + #[test] + fn test_assertion_appid_extension_output_true() { + let mut assertion = create_test_assertion(); + assertion.unsigned_extensions_output = Some(GetAssertionResponseUnsignedExtensions { + hmac_get_secret: None, + large_blob: None, + prf: None, + appid: Some(true), + }); + + let request = create_test_request(); + let model = assertion.to_idl_model(&request).unwrap(); + assert_eq!(model.client_extension_results.appid, Some(true)); + + // The output should also round-trip through the JSON wire format. + let json = serde_json::to_value(&model.client_extension_results).unwrap(); + assert_eq!( + json.get("appid").and_then(|v| v.as_bool()), + Some(true), + "JSON output should include `appid: true`" + ); + } + + #[test] + fn test_assertion_appid_extension_output_omitted_when_none() { + let mut assertion = create_test_assertion(); + assertion.unsigned_extensions_output = Some(GetAssertionResponseUnsignedExtensions { + hmac_get_secret: None, + large_blob: None, + prf: None, + appid: None, + }); + + let request = create_test_request(); + let model = assertion.to_idl_model(&request).unwrap(); + assert_eq!(model.client_extension_results.appid, None); + + let json = serde_json::to_value(&model.client_extension_results).unwrap(); + assert!( + json.get("appid").is_none(), + "JSON output should omit `appid` when not requested" + ); + } } diff --git a/libwebauthn/src/ops/webauthn/idl/get.rs b/libwebauthn/src/ops/webauthn/idl/get.rs index 83eaf63..8a899d0 100644 --- a/libwebauthn/src/ops/webauthn/idl/get.rs +++ b/libwebauthn/src/ops/webauthn/idl/get.rs @@ -54,6 +54,10 @@ pub struct GetAssertionRequestExtensionsJSON { pub large_blob: Option, pub hmac_get_secret: Option, pub prf: Option, + /// FIDO AppID extension (WebAuthn L3 §10.1.1). When the relying party has + /// existing U2F credentials registered under a legacy AppID, this URL is + /// hashed in place of the rpId to derive the U2F application parameter. + pub appid: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/libwebauthn/src/ops/webauthn/idl/response.rs b/libwebauthn/src/ops/webauthn/idl/response.rs index 5bcc095..e657257 100644 --- a/libwebauthn/src/ops/webauthn/idl/response.rs +++ b/libwebauthn/src/ops/webauthn/idl/response.rs @@ -190,6 +190,7 @@ pub struct AuthenticatorAssertionResponseJSON { /// /// Client extension outputs, with any ArrayBuffer values encoded as Base64URL. /// Extensions are optional and may include: +/// - appid: bool (authentication only, whether the FIDO AppID was used) /// - credBlob: bool /// - largeBlob: { blob: Base64URLString, written: bool } /// - prf: { results: { first: Base64URLString, second: Base64URLString } } @@ -198,6 +199,13 @@ pub struct AuthenticatorAssertionResponseJSON { #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct AuthenticationExtensionsClientOutputsJSON { + /// FIDO AppID extension output (for authentication, WebAuthn L3 §10.1.1): + /// `Some(true)` when the assertion was matched under the legacy AppID, + /// `Some(false)` when the AppID was requested but not used, + /// `None` when the extension was not requested. + #[serde(skip_serializing_if = "Option::is_none")] + pub appid: Option, + /// The credential properties extension output (for registration). #[serde(skip_serializing_if = "Option::is_none")] pub cred_props: Option, diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 03d2b6d..43f72ac 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -549,6 +549,11 @@ pub struct MakeCredentialsRequestExtensions { pub min_pin_length: Option, pub hmac_create_secret: Option, pub prf: Option, + /// FIDO AppID Exclusion extension (WebAuthn L3 §10.1.2). When set, the + /// excludeList is preflighted against both `SHA-256(rp.id)` and + /// `SHA-256(appidExclude)` so that legacy U2F-keyed credentials are + /// detected and registration is prevented. + pub appid_exclude: Option, } pub type MakeCredentialsResponseExtensions = Ctap2MakeCredentialsResponseExtensions; @@ -787,6 +792,25 @@ mod tests { )); } + #[test] + fn test_request_from_json_appid_exclude_extension() { + let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); + let req_json = json_field_add( + REQUEST_BASE_JSON, + "extensions", + r#"{"appidExclude": "https://www.example.org/u2f/origins.json"}"#, + ); + + let req: MakeCredentialRequest = + MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json) + .unwrap(); + let ext = req.extensions.expect("extensions should be present"); + assert_eq!( + ext.appid_exclude.as_deref(), + Some("https://www.example.org/u2f/origins.json") + ); + } + #[test] fn test_request_from_json_unknown_pub_key_cred_params() { let request_origin: RequestOrigin = "https://example.org".parse().unwrap(); diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index dd61a2a..f41a4f0 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -556,10 +556,21 @@ impl Ctap2GetAssertionResponseExtensions { .and_then(|ext| ext.large_blob.as_ref()) .map(|_| GetAssertionLargeBlobExtensionOutput { blob: None }); + // FIDO AppID extension: on the FIDO2 path the application parameter + // is always derived from rp.id, so if the caller requested `appid` + // we signal "not used" (Some(false)). When the extension was not + // requested, we leave it None so it doesn't appear in the JSON output. + let appid = request + .extensions + .as_ref() + .and_then(|ext| ext.appid.as_ref()) + .map(|_| false); + GetAssertionResponseUnsignedExtensions { hmac_get_secret: None, large_blob, prf, + appid, } } } @@ -669,6 +680,7 @@ mod tests { cred_blob: false, prf: None, large_blob: Some(GetAssertionLargeBlobExtension::Read), + appid: None, }); let assertion = response.into_assertion_output(&request, None); diff --git a/libwebauthn/src/proto/ctap2/preflight.rs b/libwebauthn/src/proto/ctap2/preflight.rs index 88b0233..4ee5393 100644 --- a/libwebauthn/src/proto/ctap2/preflight.rs +++ b/libwebauthn/src/proto/ctap2/preflight.rs @@ -23,51 +23,93 @@ pub async fn ctap2_preflight( credentials: &[Ctap2PublicKeyCredentialDescriptor], client_data_hash: &[u8], rp: &str, +) -> Vec { + ctap2_preflight_with_appid(channel, credentials, client_data_hash, rp, None).await +} + +/// Like [`ctap2_preflight`] but additionally tests each credential against an +/// extra "appid" relying-party identifier, per the WebAuthn L3 §10.1.2 FIDO +/// AppID Exclusion extension. If a credential is found under either the +/// canonical `rpId` or under the legacy `appidExclude`, it is kept in the +/// filtered list so the caller can refuse registration. +pub async fn ctap2_preflight_with_appid( + channel: &mut C, + credentials: &[Ctap2PublicKeyCredentialDescriptor], + client_data_hash: &[u8], + rp: &str, + appid_exclude: Option<&str>, ) -> Vec { info!("Credential list BEFORE preflight: {credentials:?}"); let mut filtered_list = Vec::new(); for credential in credentials { - let preflight_request = Ctap2GetAssertionRequest { - relying_party_id: rp.to_string(), - client_data_hash: ByteBuf::from(client_data_hash), - allow: vec![credential.clone()], - extensions: None, - options: Some(Ctap2GetAssertionOptions { - require_user_presence: false, - require_user_verification: false, - }), - pin_auth_param: None, - pin_auth_proto: None, - }; - match channel - .ctap2_get_assertion(&preflight_request, Duration::from_secs(2)) - .await - { - Ok(resp) => { - debug!("Pre-flight: Found already known credential {credential:?}"); - // This credential is known to the device - // Now we have to figure out it's ID. There are 3 options: - let id = resp - // 1. Directly in the response "credential_id" - .credential_id - // 2. In the attested_credential - .or(resp - .authenticator_data - .attested_credential - .map(|x| Ctap2PublicKeyCredentialDescriptor::from(&x))) - // 3. Neither, which is allowed, if the allow_list was of length 1, then - // we have to copy it ourselfs from the input - .unwrap_or(credential.clone()); - filtered_list.push(id); - } - Err(e) => { - debug!("Pre-flight: Filtering out {credential:?}, because of error: {e:?}"); - // This credential is unknown to the device. So we can filter it out. - // NOTE: According to spec a CTAP2_ERR_NO_CREDENTIALS should be returned, other return values have been observed. + // Test against the canonical rpId first. + if let Some(matched) = preflight_one(channel, credential, client_data_hash, rp).await { + debug!("Pre-flight: Found already known credential under rpId {credential:?}"); + filtered_list.push(matched); + continue; + } + // FIDO AppID Exclusion (WebAuthn L3 §10.1.2): if the caller supplied + // a legacy AppID, also test the credential against the AppID-derived + // application parameter. CTAP authenticators key by rpId so we pass + // the AppID URL as the rpId here; the authenticator hashes it the + // same way the U2F device hashed the original AppID. + if let Some(appid) = appid_exclude { + if let Some(matched) = preflight_one(channel, credential, client_data_hash, appid).await + { + debug!( + "Pre-flight: Found already known credential under appidExclude {credential:?}" + ); + filtered_list.push(matched); continue; } } + debug!("Pre-flight: Filtering out {credential:?}"); } info!("Credential list AFTER preflight: {filtered_list:?}"); filtered_list } + +async fn preflight_one( + channel: &mut C, + credential: &Ctap2PublicKeyCredentialDescriptor, + client_data_hash: &[u8], + rp: &str, +) -> Option { + let preflight_request = Ctap2GetAssertionRequest { + relying_party_id: rp.to_string(), + client_data_hash: ByteBuf::from(client_data_hash), + allow: vec![credential.clone()], + extensions: None, + options: Some(Ctap2GetAssertionOptions { + require_user_presence: false, + require_user_verification: false, + }), + pin_auth_param: None, + pin_auth_proto: None, + }; + match channel + .ctap2_get_assertion(&preflight_request, Duration::from_secs(2)) + .await + { + Ok(resp) => { + // This credential is known to the device under this rpId. + // Now we have to figure out its ID. There are 3 options: + let id = resp + // 1. Directly in the response "credential_id" + .credential_id + // 2. In the attested_credential + .or(resp + .authenticator_data + .attested_credential + .map(|x| Ctap2PublicKeyCredentialDescriptor::from(&x))) + // 3. Neither, which is allowed, if the allow_list was of length 1, then + // we have to copy it ourselfs from the input + .unwrap_or(credential.clone()); + Some(id) + } + Err(e) => { + debug!("Pre-flight: Not found under {rp:?}: {e:?}"); + None + } + } +} diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 5973d98..33d1af8 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -9,7 +9,7 @@ use crate::ops::u2f::{RegisterRequest, SignRequest, UpgradableResponse}; use crate::ops::webauthn::{DowngradableRequest, GetAssertionRequest, GetAssertionResponse}; use crate::ops::webauthn::{MakeCredentialRequest, MakeCredentialResponse}; use crate::proto::ctap1::Ctap1; -use crate::proto::ctap2::preflight::ctap2_preflight; +use crate::proto::ctap2::preflight::{ctap2_preflight, ctap2_preflight_with_appid}; use crate::proto::ctap2::{ Ctap2, Ctap2ClientPinRequest, Ctap2GetAssertionRequest, Ctap2MakeCredentialRequest, Ctap2UserVerificationOperation, @@ -104,11 +104,21 @@ async fn make_credential_fido2( Ctap2MakeCredentialRequest::from_webauthn_request(op, &get_info_response)?; if C::supports_preflight() { if let Some(exclude_list) = &op.exclude { - let filtered_exclude_list = ctap2_preflight( + // FIDO AppID Exclusion (WebAuthn L3 §10.1.2): if the relying + // party supplied a legacy AppID, the preflight must test + // each excludeList entry against both `SHA-256(rp.id)` and + // `SHA-256(appidExclude)` so that legacy U2F-keyed + // credentials are correctly detected. + let appid_exclude = op + .extensions + .as_ref() + .and_then(|e| e.appid_exclude.as_deref()); + let filtered_exclude_list = ctap2_preflight_with_appid( channel, exclude_list, &op.client_data_hash(), &op.relying_party.id, + appid_exclude, ) .await; ctap2_request.exclude = Some(filtered_exclude_list); @@ -246,13 +256,41 @@ async fn get_assertion_u2f( channel: &mut C, op: &GetAssertionRequest, ) -> Result { + use sha2::{Digest, Sha256}; + let sign_requests: Vec = op.try_downgrade()?; + // Precompute the AppID-derived application parameter so we can + // distinguish a match-by-rpId from a match-by-appid for the + // `clientExtensionResults.appid` output. The downgrade path emits + // both forms of SignRequest when `appid` is set. + let appid_hash: Option> = + op.extensions + .as_ref() + .and_then(|e| e.appid.as_ref()) + .map(|appid| { + let mut hasher = Sha256::default(); + hasher.update(appid.as_bytes()); + hasher.finalize().to_vec() + }); + for sign_request in sign_requests { match channel.ctap1_sign(&sign_request).await { Ok(response) => { debug!("Found successful candidate in allowList"); - return response.try_upgrade(&sign_request); + let mut upgraded = response.try_upgrade(&sign_request)?; + // Surface the FIDO AppID extension output in the + // assertion's clientExtensionResults. + if let Some(ref appid_hash) = appid_hash { + let used_appid = sign_request.app_id_hash == *appid_hash; + for assertion in upgraded.assertions.iter_mut() { + let unsigned = assertion + .unsigned_extensions_output + .get_or_insert_with(Default::default); + unsigned.appid = Some(used_appid); + } + } + return Ok(upgraded); } Err(Error::Ctap(CtapError::NoCredentials)) => { debug!("No credentials found, trying with the next.");