diff --git a/libwebauthn/examples/prf_test.rs b/libwebauthn/examples/prf_test.rs index f4e81cd..9d7b31b 100644 --- a/libwebauthn/examples/prf_test.rs +++ b/libwebauthn/examples/prf_test.rs @@ -152,7 +152,9 @@ async fn run_success_test( ) { let get_assertion = GetAssertionRequest { relying_party_id: "demo.yubico.com".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "demo.yubico.com".to_string(), + cross_origin: None, allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Preferred, extensions: Some(GetAssertionRequestExtensions { diff --git a/libwebauthn/examples/webauthn_cable.rs b/libwebauthn/examples/webauthn_cable.rs index 5bcb770..f42595c 100644 --- a/libwebauthn/examples/webauthn_cable.rs +++ b/libwebauthn/examples/webauthn_cable.rs @@ -120,8 +120,9 @@ pub async fn main() -> Result<(), Box> { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + challenge: Vec::from(challenge), origin: "example.org".to_owned(), - hash: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -159,7 +160,9 @@ pub async fn main() -> Result<(), Box> { let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions::default()), diff --git a/libwebauthn/examples/webauthn_extensions_hid.rs b/libwebauthn/examples/webauthn_extensions_hid.rs index 1feef5f..f956883 100644 --- a/libwebauthn/examples/webauthn_extensions_hid.rs +++ b/libwebauthn/examples/webauthn_extensions_hid.rs @@ -105,8 +105,9 @@ pub async fn main() -> Result<(), Box> { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + challenge: Vec::from(challenge), origin: "example.org".to_owned(), - hash: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Required), @@ -144,7 +145,9 @@ pub async fn main() -> Result<(), Box> { (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { diff --git a/libwebauthn/examples/webauthn_hid.rs b/libwebauthn/examples/webauthn_hid.rs index d05fb46..982042d 100644 --- a/libwebauthn/examples/webauthn_hid.rs +++ b/libwebauthn/examples/webauthn_hid.rs @@ -88,8 +88,9 @@ pub async fn main() -> Result<(), Box> { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + challenge: Vec::from(challenge), origin: "example.org".to_owned(), - hash: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -126,7 +127,9 @@ pub async fn main() -> Result<(), Box> { (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions::default()), diff --git a/libwebauthn/examples/webauthn_json_hid.rs b/libwebauthn/examples/webauthn_json_hid.rs index 4b08ac2..e3a538c 100644 --- a/libwebauthn/examples/webauthn_json_hid.rs +++ b/libwebauthn/examples/webauthn_json_hid.rs @@ -8,7 +8,8 @@ use tokio::sync::broadcast::Receiver; use tracing_subscriber::{self, EnvFilter}; use libwebauthn::ops::webauthn::{ - GetAssertionRequest, MakeCredentialRequest, RelyingPartyId, WebAuthnIDL as _, + GetAssertionRequest, JsonFormat, MakeCredentialRequest, RelyingPartyId, + WebAuthnIDL as _, WebAuthnIDLResponse as _, }; use libwebauthn::pin::PinRequestReason; use libwebauthn::transport::hid::list_devices; @@ -132,6 +133,20 @@ pub async fn main() -> Result<(), Box> { .unwrap(); println!("WebAuthn MakeCredential response: {:?}", response); + // Serialize the response back to JSON using the original request as context. + // The request contains the client_data_json bytes that were built during parsing. + match response.to_json(&make_credentials_request, JsonFormat::Prettified) { + Ok(response_json) => { + println!( + "WebAuthn MakeCredential response (JSON):\n{}", + response_json + ); + } + Err(e) => { + eprintln!("Failed to serialize MakeCredential response: {:?}", e); + } + } + let request_json = r#" { "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu", @@ -160,6 +175,18 @@ pub async fn main() -> Result<(), Box> { } .unwrap(); println!("WebAuthn GetAssertion response: {:?}", response); + + // Serialize the response back to JSON using the original request as context. + for assertion in &response.assertions { + match assertion.to_json(&get_assertion, JsonFormat::Prettified) { + Ok(assertion_json) => { + println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json); + } + Err(e) => { + eprintln!("Failed to serialize GetAssertion response: {:?}", e); + } + } + } } Ok(()) diff --git a/libwebauthn/examples/webauthn_preflight_hid.rs b/libwebauthn/examples/webauthn_preflight_hid.rs index 97f876d..8d43975 100644 --- a/libwebauthn/examples/webauthn_preflight_hid.rs +++ b/libwebauthn/examples/webauthn_preflight_hid.rs @@ -161,8 +161,9 @@ async fn make_credential_call( ) -> Result { let challenge: [u8; 32] = thread_rng().gen(); let make_credentials_request = MakeCredentialRequest { + challenge: Vec::from(challenge), origin: "example.org".to_owned(), - hash: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -199,7 +200,9 @@ async fn get_assertion_call( let challenge: [u8; 32] = thread_rng().gen(); let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: allow_list, user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions::default()), diff --git a/libwebauthn/examples/webauthn_prf_hid.rs b/libwebauthn/examples/webauthn_prf_hid.rs index e279815..867309a 100644 --- a/libwebauthn/examples/webauthn_prf_hid.rs +++ b/libwebauthn/examples/webauthn_prf_hid.rs @@ -13,8 +13,8 @@ use tracing_subscriber::{self, EnvFilter}; use libwebauthn::ops::webauthn::{ GetAssertionHmacOrPrfInput, GetAssertionRequest, GetAssertionRequestExtensions, - MakeCredentialPrfInput, MakeCredentialRequest, MakeCredentialsRequestExtensions, PRFValue, - PrfInput, ResidentKeyRequirement, UserVerificationRequirement, + MakeCredentialPrfInput, MakeCredentialRequest, MakeCredentialsRequestExtensions, + PRFValue, PrfInput, ResidentKeyRequirement, UserVerificationRequirement, }; use libwebauthn::pin::PinRequestReason; use libwebauthn::proto::ctap2::{ @@ -99,8 +99,9 @@ pub async fn main() -> Result<(), Box> { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + challenge: Vec::from(challenge), origin: "example.org".to_owned(), - hash: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Required), @@ -423,7 +424,9 @@ async fn run_success_test( ) { let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { @@ -465,7 +468,9 @@ async fn run_failed_test( ) { let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: credential.map(|x| vec![x.clone()]).unwrap_or_default(), user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { diff --git a/libwebauthn/src/ops/u2f.rs b/libwebauthn/src/ops/u2f.rs index f21dd6a..9c1f729 100644 --- a/libwebauthn/src/ops/u2f.rs +++ b/libwebauthn/src/ops/u2f.rs @@ -9,8 +9,8 @@ use x509_parser::nom::AsBytes; use super::webauthn::MakeCredentialRequest; use crate::fido::{AttestedCredentialData, AuthenticatorData, AuthenticatorDataFlags}; use crate::ops::webauthn::{ - GetAssertionRequest, GetAssertionResponse, - MakeCredentialResponse, UserVerificationRequirement, + GetAssertionRequest, GetAssertionResponse, MakeCredentialResponse, + UserVerificationRequirement, }; use crate::proto::ctap1::{Ctap1RegisterRequest, Ctap1SignRequest}; use crate::proto::ctap1::{Ctap1RegisterResponse, Ctap1SignResponse}; @@ -133,7 +133,7 @@ impl UpgradableResponse for Regis // states a different length range. let attestation_statement = Ctap2AttestationStatement::FidoU2F(FidoU2fAttestationStmt { signature: ByteBuf::from(self.signature.clone()), - certificate: ByteBuf::from(self.attestation.clone()), + certificates: vec![ByteBuf::from(self.attestation.clone())], }); // Let attestationObject be a CBOR map (see "attObj" in Generating an Attestation Object [WebAuthn]) with the @@ -201,7 +201,9 @@ impl UpgradableResponse for SignResponse { // something like that here. In reality, we only need `extensions: None` currently. let orig_request = GetAssertionRequest { relying_party_id: String::new(), // We don't have access to that info here, but we don't need it either - hash: request.app_id_hash.clone(), + challenge: Vec::new(), // U2F path doesn't use client_data for response serialization + origin: String::new(), + cross_origin: None, allow: vec![Ctap2PublicKeyCredentialDescriptor { r#type: Ctap2PublicKeyCredentialType::PublicKey, id: request.key_handle.clone().into(), diff --git a/libwebauthn/src/ops/webauthn/client_data.rs b/libwebauthn/src/ops/webauthn/client_data.rs index 9145a48..18de670 100644 --- a/libwebauthn/src/ops/webauthn/client_data.rs +++ b/libwebauthn/src/ops/webauthn/client_data.rs @@ -13,7 +13,8 @@ pub struct ClientData { } impl ClientData { - pub fn hash(&self) -> Vec { + /// Returns the canonical JSON representation of the client data. + pub fn to_json_bytes(&self) -> Vec { let op_str = match &self.operation { Operation::MakeCredential => "webauthn.create", Operation::GetAssertion => "webauthn.get", @@ -25,11 +26,13 @@ impl ClientData { } else { "false" }; - let json = - format!("{{\"type\":\"{op_str}\",\"challenge\":\"{challenge_str}\",\"origin\":\"{origin_str}\",\"crossOrigin\":{cross_origin_str}}}"); + format!("{{\"type\":\"{op_str}\",\"challenge\":\"{challenge_str}\",\"origin\":\"{origin_str}\",\"crossOrigin\":{cross_origin_str}}}").into_bytes() + } + pub fn hash(&self) -> Vec { + let json_bytes = self.to_json_bytes(); let mut hasher = Sha256::new(); - hasher.update(json.as_bytes()); + hasher.update(&json_bytes); hasher.finalize().to_vec() } } diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index dc1e10b..92dc906 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -13,7 +13,12 @@ use crate::{ HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson, PublicKeyCredentialRequestOptionsJSON, }, - FromInnerModel, JsonError, + response::{ + AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, + AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON, + PRFOutputJSON, PRFValuesJSON, ResponseSerializationError, WebAuthnIDLResponse, + }, + Base64UrlString, FromInnerModel, JsonError, }, Operation, WebAuthnIDL, }, @@ -39,13 +44,34 @@ pub struct PRFValue { #[derive(Debug, Clone, PartialEq)] pub struct GetAssertionRequest { pub relying_party_id: String, - pub hash: Vec, + pub challenge: Vec, + pub origin: String, + pub cross_origin: Option, pub allow: Vec, pub extensions: Option, pub user_verification: UserVerificationRequirement, pub timeout: Duration, } +impl GetAssertionRequest { + fn client_data(&self) -> ClientData { + ClientData { + operation: Operation::GetAssertion, + challenge: self.challenge.clone(), + origin: self.origin.clone(), + cross_origin: self.cross_origin, + } + } + + pub fn client_data_hash(&self) -> Vec { + self.client_data().hash() + } + + pub fn client_data_json(&self) -> Vec { + self.client_data().to_json_bytes() + } +} + #[derive(thiserror::Error, Debug)] pub enum GetAssertionRequestParsingError { /// The client must throw an "EncodingError" DOMException. @@ -118,16 +144,11 @@ impl FromInnerModel, } +/// Context required for serializing a GetAssertion response to JSON. #[derive(Debug, Clone)] pub struct GetAssertionResponse { pub assertions: Vec, @@ -362,6 +384,102 @@ pub struct Assertion { pub attestation_statement: Option, } +impl WebAuthnIDLResponse for Assertion { + type InnerModel = AuthenticationResponseJSON; + type Context = GetAssertionRequest; + + fn to_inner_model( + &self, + request: &Self::Context, + ) -> Result { + // Get credential ID - either from credential_id field or from authenticator_data + let credential_id_bytes = self + .credential_id + .as_ref() + .map(|cred| cred.id.to_vec()) + .unwrap_or_default(); + + let id = base64_url::encode(&credential_id_bytes); + let raw_id = Base64UrlString::from(credential_id_bytes); + + // Serialize authenticator data + let authenticator_data_bytes = self + .authenticator_data + .to_response_bytes() + .map_err(|e| ResponseSerializationError::AuthenticatorDataError(e.to_string()))?; + + // Get user handle if available + let user_handle = self + .user + .as_ref() + .map(|user| Base64UrlString::from(user.id.as_ref())); + + // Build client extension results + let client_extension_results = self.build_client_extension_results(); + + Ok(AuthenticationResponseJSON { + id, + raw_id, + response: AuthenticatorAssertionResponseJSON { + client_data_json: Base64UrlString::from(request.client_data_json()), + authenticator_data: Base64UrlString::from(authenticator_data_bytes), + signature: Base64UrlString::from(self.signature.clone()), + user_handle, + }, + authenticator_attachment: None, + client_extension_results, + r#type: "public-key".to_string(), + }) + } +} + +impl Assertion { + fn build_client_extension_results(&self) -> AuthenticationExtensionsClientOutputsJSON { + let mut results = AuthenticationExtensionsClientOutputsJSON::default(); + + if let Some(unsigned_ext) = &self.unsigned_extensions_output { + // HMAC-secret extension output + if let Some(hmac_output) = &unsigned_ext.hmac_get_secret { + results.hmac_get_secret = Some(HMACGetSecretOutputJSON { + output1: Base64UrlString::from(hmac_output.output1.as_slice()), + output2: hmac_output + .output2 + .as_ref() + .map(|o| Base64UrlString::from(o.as_slice())), + }); + } + + // Large blob extension output + if let Some(large_blob) = &unsigned_ext.large_blob { + results.large_blob = Some(LargeBlobOutputJSON { + supported: None, + blob: large_blob + .blob + .as_ref() + .map(|b| Base64UrlString::from(b.as_slice())), + written: None, // Write not yet supported + }); + } + + // PRF extension output + if let Some(prf_output) = &unsigned_ext.prf { + results.prf = Some(PRFOutputJSON { + enabled: None, + results: prf_output.results.as_ref().map(|prf_value| PRFValuesJSON { + first: Base64UrlString::from(prf_value.first.as_slice()), + second: prf_value + .second + .as_ref() + .map(|s| Base64UrlString::from(s.as_slice())), + }), + }); + } + } + + results + } +} + impl From<&[Assertion]> for GetAssertionResponse { fn from(assertions: &[Assertion]) -> Self { Self { @@ -410,7 +528,7 @@ impl DowngradableRequest> for GetAssertionRequest { // --> 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.hash; + 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). @@ -422,7 +540,7 @@ impl DowngradableRequest> for GetAssertionRequest { 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) + SignRequest::new_upgraded(&rp_id_hash, &challenge, credential_id, self.timeout) }) .collect(); trace!(?downgraded_requests); @@ -458,15 +576,11 @@ mod tests { "#; fn request_base() -> GetAssertionRequest { - let client_data_json = ClientData { - operation: Operation::GetAssertion, + GetAssertionRequest { + relying_party_id: "example.org".to_owned(), challenge: base64_url::decode("Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu").unwrap(), origin: "example.org".to_string(), cross_origin: None, - }; - GetAssertionRequest { - relying_party_id: "example.org".to_owned(), - hash: client_data_json.hash(), allow: vec![Ctap2PublicKeyCredentialDescriptor { r#type: Ctap2PublicKeyCredentialType::PublicKey, id: ByteBuf::from(base64_url::decode("bXktY3JlZGVudGlhbC1pZA").unwrap()), @@ -587,4 +701,130 @@ mod tests { panic!("Expected PRF extension with correct values"); } } + + // Tests for response JSON serialization + + fn create_test_assertion() -> Assertion { + use crate::fido::{AuthenticatorData, AuthenticatorDataFlags}; + + let authenticator_data = AuthenticatorData { + rp_id_hash: [0u8; 32], + flags: AuthenticatorDataFlags::USER_PRESENT, + signature_count: 1, + attested_credential: None, + extensions: None, + }; + + Assertion { + credential_id: Some(Ctap2PublicKeyCredentialDescriptor { + r#type: Ctap2PublicKeyCredentialType::PublicKey, + id: ByteBuf::from(vec![0x01, 0x02, 0x03, 0x04]), + transports: None, + }), + authenticator_data, + signature: vec![0xDE, 0xAD, 0xC0, 0xDE], + user: None, + credentials_count: None, + user_selected: None, + large_blob_key: None, + unsigned_extensions_output: None, + enterprise_attestation: None, + attestation_statement: None, + } + } + + fn create_test_request() -> GetAssertionRequest { + GetAssertionRequest { + relying_party_id: "example.org".to_owned(), + challenge: b"DEADCODE_challenge".to_vec(), + origin: "example.org".to_string(), + cross_origin: None, + allow: vec![], + extensions: None, + user_verification: UserVerificationRequirement::Preferred, + timeout: Duration::from_secs(30), + } + } + + #[test] + fn test_assertion_to_json() { + use crate::ops::webauthn::idl::response::JsonFormat; + + let assertion = create_test_assertion(); + let request = create_test_request(); + let json = assertion.to_json(&request, JsonFormat::default()); + assert!(json.is_ok()); + + let json_str = json.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + // Verify required fields + assert!(parsed.get("id").is_some()); + assert!(parsed.get("rawId").is_some()); + assert!(parsed.get("type").is_some()); + assert_eq!(parsed.get("type").unwrap(), "public-key"); + + // Verify response object + let response_obj = parsed.get("response").unwrap(); + assert!(response_obj.get("clientDataJSON").is_some()); + assert!(response_obj.get("authenticatorData").is_some()); + assert!(response_obj.get("signature").is_some()); + } + + #[test] + fn test_assertion_to_inner_model() { + let assertion = create_test_assertion(); + let request = create_test_request(); + let model = assertion.to_inner_model(&request).unwrap(); + + // Verify the credential ID + assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]); + assert_eq!(model.r#type, "public-key"); + + // Verify signature + assert_eq!(model.response.signature.0, vec![0xDE, 0xAD, 0xC0, 0xDE]); + } + + #[test] + fn test_assertion_with_user_handle() { + use crate::proto::ctap2::Ctap2PublicKeyCredentialUserEntity; + + let mut assertion = create_test_assertion(); + assertion.user = Some(Ctap2PublicKeyCredentialUserEntity::new( + b"test-user-id", + "testuser", + "Test User", + )); + + let request = create_test_request(); + let model = assertion.to_inner_model(&request).unwrap(); + + // Verify user handle is present + assert!(model.response.user_handle.is_some()); + assert_eq!( + model.response.user_handle.as_ref().unwrap().0, + b"test-user-id".to_vec() + ); + } + + #[test] + fn test_assertion_with_extensions() { + let mut assertion = create_test_assertion(); + assertion.unsigned_extensions_output = Some(GetAssertionResponseUnsignedExtensions { + hmac_get_secret: None, + large_blob: None, + prf: Some(GetAssertionPrfOutput { + results: Some(PRFValue { + first: [0x01u8; 32], + second: None, + }), + }), + }); + + let request = create_test_request(); + let model = assertion.to_inner_model(&request).unwrap(); + + // Verify extension outputs - PRF should be set + assert!(model.client_extension_results.prf.is_some()); + } } diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs index c392756..e2d6685 100644 --- a/libwebauthn/src/ops/webauthn/idl/mod.rs +++ b/libwebauthn/src/ops/webauthn/idl/mod.rs @@ -1,9 +1,17 @@ mod base64url; pub mod create; pub mod get; +pub mod response; pub mod rpid; pub use base64url::Base64UrlString; +pub use response::{ + AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, + AuthenticatorAssertionResponseJSON, AuthenticatorAttestationResponseJSON, + CredentialPropertiesOutputJSON, HMACGetSecretOutputJSON, JsonFormat, LargeBlobOutputJSON, + PRFOutputJSON, PRFValuesJSON, RegistrationResponseJSON, ResponseSerializationError, + WebAuthnIDLResponse, +}; use rpid::RelyingPartyId; diff --git a/libwebauthn/src/ops/webauthn/idl/response.rs b/libwebauthn/src/ops/webauthn/idl/response.rs new file mode 100644 index 0000000..5471c5d --- /dev/null +++ b/libwebauthn/src/ops/webauthn/idl/response.rs @@ -0,0 +1,259 @@ +//! JSON response models for WebAuthn responses. +//! +//! These types follow the WebAuthn Level 3 specification for JSON serialization: +//! - `RegistrationResponseJSON` for credential creation responses (§5.1 toJSON()) +//! - `AuthenticationResponseJSON` for assertion responses (§5.1 toJSON()) +//! +//! See: https://www.w3.org/TR/webauthn-3/#sctn-public-key-credential-json + +use serde::Serialize; + +use super::Base64UrlString; + +/// JSON output format options. +#[derive(Debug, Clone, Copy, Default)] +pub enum JsonFormat { + /// Minified JSON (default). + #[default] + Minified, + /// Pretty-printed JSON with indentation. + Prettified, +} + +/// Error type for WebAuthn response serialization. +#[derive(thiserror::Error, Debug)] +pub enum ResponseSerializationError { + /// Failed to serialize authenticator data. + #[error("Failed to serialize authenticator data: {0}")] + AuthenticatorDataError(String), + + /// Failed to serialize attestation object. + #[error("Failed to serialize attestation object: {0}")] + AttestationObjectError(String), + + /// Failed to serialize public key. + #[error("Failed to serialize public key: {0}")] + PublicKeyError(String), + + /// Failed to serialize to JSON. + #[error("Failed to serialize to JSON: {0}")] + JsonError(#[from] serde_json::Error), + + /// Failed to serialize to CBOR. + #[error("Failed to serialize to CBOR: {0}")] + CborError(String), +} + +/// Trait for WebAuthn response types that can be serialized to JSON. +/// +/// This is the inverse of `WebAuthnIDL` - it converts WebAuthn response models +/// to JSON-serializable intermediate models, which can then be serialized to JSON. +pub trait WebAuthnIDLResponse: Sized { + /// The JSON-serializable intermediate model type. + type InnerModel: Serialize; + + /// Context required for serialization (e.g., client data JSON). + type Context; + + /// Converts this response to a JSON-serializable intermediate model. + fn to_inner_model( + &self, + ctx: &Self::Context, + ) -> Result; + + /// Serializes this response to a JSON string. + fn to_json( + &self, + ctx: &Self::Context, + format: JsonFormat, + ) -> Result { + let model = self.to_inner_model(ctx)?; + match format { + JsonFormat::Minified => Ok(serde_json::to_string(&model)?), + JsonFormat::Prettified => Ok(serde_json::to_string_pretty(&model)?), + } + } +} + +/// dictionary RegistrationResponseJSON { +/// required DOMString id; +/// required Base64URLString rawId; +/// required AuthenticatorAttestationResponseJSON response; +/// DOMString authenticatorAttachment; +/// required AuthenticationExtensionsClientOutputsJSON clientExtensionResults; +/// required DOMString type; +/// }; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationResponseJSON { + /// The credential ID, base64url-encoded. + pub id: String, + /// The raw credential ID, base64url-encoded. + pub raw_id: Base64UrlString, + /// The authenticator's response. + pub response: AuthenticatorAttestationResponseJSON, + /// The authenticator attachment modality. + #[serde(skip_serializing_if = "Option::is_none")] + pub authenticator_attachment: Option, + /// Client extension results. + pub client_extension_results: AuthenticationExtensionsClientOutputsJSON, + /// The credential type (always "public-key"). + pub r#type: String, +} + +/// dictionary AuthenticatorAttestationResponseJSON { +/// required Base64URLString clientDataJSON; +/// required Base64URLString authenticatorData; +/// required sequence transports; +/// Base64URLString publicKey; +/// required COSEAlgorithmIdentifier publicKeyAlgorithm; +/// required Base64URLString attestationObject; +/// }; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticatorAttestationResponseJSON { + /// The client data JSON, base64url-encoded. + #[serde(rename = "clientDataJSON")] + pub client_data_json: Base64UrlString, + /// The authenticator data, base64url-encoded. + pub authenticator_data: Base64UrlString, + /// The transports the authenticator is believed to support. + pub transports: Vec, + /// The public key in SubjectPublicKeyInfo format, base64url-encoded. + /// May be None if the public key algorithm is not supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_key: Option, + /// The COSE algorithm identifier. + pub public_key_algorithm: i64, + /// The attestation object, base64url-encoded. + pub attestation_object: Base64UrlString, +} + +/// dictionary AuthenticationResponseJSON { +/// required DOMString id; +/// required Base64URLString rawId; +/// required AuthenticatorAssertionResponseJSON response; +/// DOMString authenticatorAttachment; +/// required AuthenticationExtensionsClientOutputsJSON clientExtensionResults; +/// required DOMString type; +/// }; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationResponseJSON { + /// The credential ID, base64url-encoded. + pub id: String, + /// The raw credential ID, base64url-encoded. + pub raw_id: Base64UrlString, + /// The authenticator's response. + pub response: AuthenticatorAssertionResponseJSON, + /// The authenticator attachment modality. + #[serde(skip_serializing_if = "Option::is_none")] + pub authenticator_attachment: Option, + /// Client extension results. + pub client_extension_results: AuthenticationExtensionsClientOutputsJSON, + /// The credential type (always "public-key"). + pub r#type: String, +} + +/// dictionary AuthenticatorAssertionResponseJSON { +/// required Base64URLString clientDataJSON; +/// required Base64URLString authenticatorData; +/// required Base64URLString signature; +/// Base64URLString userHandle; +/// }; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticatorAssertionResponseJSON { + /// The client data JSON, base64url-encoded. + #[serde(rename = "clientDataJSON")] + pub client_data_json: Base64UrlString, + /// The authenticator data, base64url-encoded. + pub authenticator_data: Base64UrlString, + /// The signature, base64url-encoded. + pub signature: Base64UrlString, + /// The user handle, base64url-encoded. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_handle: Option, +} + +/// dictionary AuthenticationExtensionsClientOutputsJSON { +/// }; +/// +/// Client extension outputs, with any ArrayBuffer values encoded as Base64URL. +/// Extensions are optional and may include: +/// - credBlob: bool +/// - largeBlob: { blob: Base64URLString, written: bool } +/// - prf: { results: { first: Base64URLString, second: Base64URLString } } +/// - hmacGetSecret: { output1: Base64URLString, output2: Base64URLString } +/// - credProps: { rk: bool } +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationExtensionsClientOutputsJSON { + /// The credential properties extension output (for registration). + #[serde(skip_serializing_if = "Option::is_none")] + pub cred_props: Option, + + /// Whether the credential was created with hmac-secret support. + #[serde(skip_serializing_if = "Option::is_none")] + pub hmac_create_secret: Option, + + /// HMAC-secret extension output (for authentication). + #[serde(skip_serializing_if = "Option::is_none")] + pub hmac_get_secret: Option, + + /// Large blob extension output. + #[serde(skip_serializing_if = "Option::is_none")] + pub large_blob: Option, + + /// PRF extension output. + #[serde(skip_serializing_if = "Option::is_none")] + pub prf: Option, +} + +/// Credential properties extension output. +#[derive(Debug, Clone, Serialize)] +pub struct CredentialPropertiesOutputJSON { + #[serde(skip_serializing_if = "Option::is_none")] + pub rk: Option, +} + +/// HMAC-secret extension output for authentication. +#[derive(Debug, Clone, Serialize)] +pub struct HMACGetSecretOutputJSON { + pub output1: Base64UrlString, + #[serde(skip_serializing_if = "Option::is_none")] + pub output2: Option, +} + +/// Large blob extension output. +#[derive(Debug, Clone, Serialize)] +pub struct LargeBlobOutputJSON { + /// For registration: whether large blob storage is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub supported: Option, + /// For authentication (read): the blob data, base64url-encoded. + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, + /// For authentication (write): whether the write was successful. + #[serde(skip_serializing_if = "Option::is_none")] + pub written: Option, +} + +/// PRF extension output. +#[derive(Debug, Clone, Serialize)] +pub struct PRFOutputJSON { + /// For registration: whether PRF is enabled. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// For authentication: the PRF results. + #[serde(skip_serializing_if = "Option::is_none")] + pub results: Option, +} + +/// PRF values in JSON format. +#[derive(Debug, Clone, Serialize)] +pub struct PRFValuesJSON { + pub first: Base64UrlString, + #[serde(skip_serializing_if = "Option::is_none")] + pub second: Option, +} diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 0e6dd25..7edac05 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -11,15 +11,20 @@ use crate::{ ops::webauthn::{ client_data::ClientData, idl::{ - create::PublicKeyCredentialCreationOptionsJSON, Base64UrlString, FromInnerModel, - JsonError, WebAuthnIDL, + create::PublicKeyCredentialCreationOptionsJSON, + response::{ + AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON, + CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, + RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse, + }, + Base64UrlString, FromInnerModel, JsonError, WebAuthnIDL, }, Operation, RelyingPartyId, }, proto::{ ctap1::{Ctap1RegisteredKey, Ctap1Version}, ctap2::{ - Ctap2AttestationStatement, Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, + cbor, Ctap2AttestationStatement, Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2GetInfoResponse, Ctap2MakeCredentialsResponseExtensions, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, @@ -40,6 +45,147 @@ pub struct MakeCredentialResponse { pub unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions, } +/// Serializable attestation object for CBOR encoding. +#[derive(Debug, Clone, Serialize)] +struct AttestationObject<'a> { + #[serde(rename = "fmt")] + format: &'a str, + #[serde(rename = "authData", with = "serde_bytes")] + auth_data: &'a [u8], + #[serde(rename = "attStmt")] + attestation_statement: &'a Ctap2AttestationStatement, +} + +impl WebAuthnIDLResponse for MakeCredentialResponse { + type InnerModel = RegistrationResponseJSON; + type Context = MakeCredentialRequest; + + fn to_inner_model( + &self, + request: &Self::Context, + ) -> Result { + // Get credential ID from attested credential data + let credential_id_bytes = self + .authenticator_data + .attested_credential + .as_ref() + .map(|cred| cred.credential_id.clone()) + .unwrap_or_default(); + + let id = base64_url::encode(&credential_id_bytes); + let raw_id = Base64UrlString::from(credential_id_bytes); + + // Serialize authenticator data + let authenticator_data_bytes = self + .authenticator_data + .to_response_bytes() + .map_err(|e| ResponseSerializationError::AuthenticatorDataError(e.to_string()))?; + + // Get public key algorithm from attested credential data + let public_key_algorithm = self + .authenticator_data + .attested_credential + .as_ref() + .map(|cred| Self::get_public_key_algorithm(&cred.credential_public_key)) + .unwrap_or(Ctap2COSEAlgorithmIdentifier::ES256 as i64); + + // Serialize public key to COSE key format + let public_key = self + .authenticator_data + .attested_credential + .as_ref() + .map(|cred| { + cbor::to_vec(&cred.credential_public_key) + .map(Base64UrlString::from) + .map_err(|e| ResponseSerializationError::PublicKeyError(e.to_string())) + }) + .transpose()?; + + // Build attestation object (CBOR map with authData, fmt, attStmt) + let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?; + + // Get transports (we don't have direct access, so return empty for now) + let transports = Vec::new(); + + // Build client extension results + let client_extension_results = self.build_client_extension_results(); + + Ok(RegistrationResponseJSON { + id, + raw_id, + response: AuthenticatorAttestationResponseJSON { + client_data_json: Base64UrlString::from(request.client_data_json()), + authenticator_data: Base64UrlString::from(authenticator_data_bytes), + transports, + public_key, + public_key_algorithm, + attestation_object: Base64UrlString::from(attestation_object_bytes), + }, + authenticator_attachment: None, + client_extension_results, + r#type: "public-key".to_string(), + }) + } +} + +impl MakeCredentialResponse { + /// Get the COSE algorithm identifier from the public key variant + fn get_public_key_algorithm(key: &cosey::PublicKey) -> i64 { + match key { + cosey::PublicKey::P256Key(_) => Ctap2COSEAlgorithmIdentifier::ES256 as i64, + cosey::PublicKey::EcdhEsHkdf256Key(_) => -25, // ECDH-ES + HKDF-256 + cosey::PublicKey::Ed25519Key(_) => Ctap2COSEAlgorithmIdentifier::EDDSA as i64, + cosey::PublicKey::TotpKey(_) => 0, // No standard algorithm for TOTP + } + } + + fn build_attestation_object( + &self, + authenticator_data_bytes: &[u8], + ) -> Result, ResponseSerializationError> { + let attestation_object = AttestationObject { + format: &self.format, + auth_data: authenticator_data_bytes, + attestation_statement: &self.attestation_statement, + }; + + cbor::to_vec(&attestation_object) + .map_err(|e| ResponseSerializationError::AttestationObjectError(e.to_string())) + } + + fn build_client_extension_results(&self) -> AuthenticationExtensionsClientOutputsJSON { + let mut results = AuthenticationExtensionsClientOutputsJSON::default(); + let unsigned_ext = &self.unsigned_extensions_output; + + // Credential properties extension + if let Some(cred_props) = &unsigned_ext.cred_props { + results.cred_props = Some(CredentialPropertiesOutputJSON { rk: cred_props.rk }); + } + + // HMAC-secret extension (hmacCreateSecret) + results.hmac_create_secret = unsigned_ext.hmac_create_secret; + + // Large blob extension + if let Some(large_blob) = &unsigned_ext.large_blob { + results.large_blob = Some(LargeBlobOutputJSON { + supported: large_blob.supported, + blob: None, + written: None, + }); + } + + // PRF extension + if let Some(prf) = &unsigned_ext.prf { + results.prf = Some(PRFOutputJSON { + enabled: prf.enabled, + results: None, + }); + } + + results + } +} + #[derive(Debug, Default, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct MakeCredentialsResponseUnsignedExtensions { @@ -172,8 +318,12 @@ pub enum ResidentKeyRequirement { #[derive(Debug, Clone, PartialEq)] pub struct MakeCredentialRequest { - pub hash: Vec, + /// The challenge from the relying party. + pub challenge: Vec, + /// The origin of the request. pub origin: String, + /// Whether the request is cross-origin (optional per WebAuthn spec). + pub cross_origin: Option, /// rpEntity pub relying_party: Ctap2PublicKeyCredentialRpEntity, /// userEntity @@ -189,6 +339,28 @@ pub struct MakeCredentialRequest { pub timeout: Duration, } +impl MakeCredentialRequest { + /// Builds the ClientData for this request. + fn client_data(&self) -> ClientData { + ClientData { + operation: Operation::MakeCredential, + challenge: self.challenge.clone(), + origin: self.origin.clone(), + cross_origin: self.cross_origin, + } + } + + /// Computes the client data hash (SHA-256 of the client data JSON). + pub fn client_data_hash(&self) -> Vec { + self.client_data().hash() + } + + /// Returns the client data JSON bytes for response serialization. + pub fn client_data_json(&self) -> Vec { + self.client_data().to_json_bytes() + } +} + impl FromInnerModel for MakeCredentialRequest { @@ -222,16 +394,10 @@ impl FromInnerModel Self { Self { - hash: vec![0; 32], + challenge: Vec::new(), + origin: "example.org".to_owned(), + cross_origin: Some(false), relying_party: Ctap2PublicKeyCredentialRpEntity::dummy(), user: Ctap2PublicKeyCredentialUserEntity::dummy(), algorithms: vec![Ctap2CredentialType::default()], exclude: None, extensions: None, - origin: "example.org".to_owned(), resident_key: None, user_verification: UserVerificationRequirement::Discouraged, timeout: Duration::from_secs(10), @@ -428,7 +595,7 @@ impl DowngradableRequest for MakeCredentialRequest { let downgraded = RegisterRequest { version: Ctap1Version::U2fV2, app_id_hash: rp_id_hash, - challenge: self.hash.clone(), + challenge: self.client_data_hash(), registered_keys: self .exclude .as_ref() @@ -499,15 +666,9 @@ mod tests { fn request_base() -> MakeCredentialRequest { MakeCredentialRequest { + challenge: base64_url::decode("Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu").unwrap(), origin: "example.org".to_string(), - hash: ClientData { - operation: Operation::MakeCredential, - challenge: base64_url::decode("Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu") - .unwrap(), - origin: "example.org".to_string(), - cross_origin: None, - } - .hash(), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(b"userid", "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -631,4 +792,183 @@ mod tests { MakeCredentialRequest::from_json(&rpid, &req_json).unwrap(); assert_eq!(req.timeout, DEFAULT_TIMEOUT); } + + // Tests for response JSON serialization + + fn create_test_response() -> MakeCredentialResponse { + use crate::fido::{AttestedCredentialData, AuthenticatorData, AuthenticatorDataFlags}; + use cosey::Bytes; + use std::collections::BTreeMap; + + // Create a simple attested credential with a P256 key + let credential_id = vec![0x01, 0x02, 0x03, 0x04]; + let aaguid = [0u8; 16]; + + // Create a P256 public key for testing + let public_key = cosey::PublicKey::P256Key(cosey::P256PublicKey { + x: Bytes::from_slice(&[0u8; 32]).unwrap(), + y: Bytes::from_slice(&[0u8; 32]).unwrap(), + }); + + let attested_credential = AttestedCredentialData { + aaguid, + credential_id, + credential_public_key: public_key, + }; + + let authenticator_data = AuthenticatorData { + rp_id_hash: [0u8; 32], + flags: AuthenticatorDataFlags::USER_PRESENT, + signature_count: 0, + attested_credential: Some(attested_credential), + extensions: None, + }; + + MakeCredentialResponse { + format: "none".to_string(), + authenticator_data, + attestation_statement: Ctap2AttestationStatement::None(BTreeMap::new()), + enterprise_attestation: None, + large_blob_key: None, + unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions::default(), + } + } + + fn create_test_request() -> MakeCredentialRequest { + MakeCredentialRequest { + challenge: b"DEADCODE_challenge".to_vec(), + origin: "example.org".to_string(), + cross_origin: None, + relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), + user: Ctap2PublicKeyCredentialUserEntity::new(b"userid", "mario.rossi", "Mario Rossi"), + resident_key: Some(ResidentKeyRequirement::Discouraged), + user_verification: UserVerificationRequirement::Preferred, + algorithms: vec![Ctap2CredentialType::default()], + exclude: None, + extensions: None, + timeout: Duration::from_secs(30), + } + } + + #[test] + fn test_response_to_json() { + use crate::ops::webauthn::idl::response::JsonFormat; + + let response = create_test_response(); + let request = create_test_request(); + let json = response.to_json(&request, JsonFormat::default()); + assert!(json.is_ok()); + + let json_str = json.unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + // Verify required fields + assert!(parsed.get("id").is_some()); + assert!(parsed.get("rawId").is_some()); + assert!(parsed.get("type").is_some()); + assert_eq!(parsed.get("type").unwrap(), "public-key"); + + // Verify response object + let response_obj = parsed.get("response").unwrap(); + assert!(response_obj.get("clientDataJSON").is_some()); + assert!(response_obj.get("authenticatorData").is_some()); + assert!(response_obj.get("attestationObject").is_some()); + assert!(response_obj.get("publicKeyAlgorithm").is_some()); + + // Verify algorithm is ES256 (-7) for P256 key + assert_eq!( + response_obj.get("publicKeyAlgorithm").unwrap(), + Ctap2COSEAlgorithmIdentifier::ES256 as i64 + ); + } + + #[test] + fn test_response_to_inner_model() { + let response = create_test_response(); + let request = create_test_request(); + let model = response.to_inner_model(&request).unwrap(); + + // Verify the credential ID + assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]); + assert_eq!(model.r#type, "public-key"); + + // Verify attestation response + assert_eq!( + model.response.public_key_algorithm, + Ctap2COSEAlgorithmIdentifier::ES256 as i64 + ); + assert!(model.response.transports.is_empty()); + } + + #[test] + fn test_response_attestation_object_format() { + let response = create_test_response(); + let request = create_test_request(); + let model = response.to_inner_model(&request).unwrap(); + + // Decode the attestation object + let attestation_bytes = model.response.attestation_object.0; + let attestation: cbor::Value = cbor::from_slice(&attestation_bytes).unwrap(); + + // Verify it's a map with the expected keys + if let cbor::Value::Map(map) = attestation { + let has_fmt = map + .keys() + .any(|k| matches!(k, cbor::Value::Text(s) if s == "fmt")); + let has_auth_data = map + .keys() + .any(|k| matches!(k, cbor::Value::Text(s) if s == "authData")); + let has_att_stmt = map + .keys() + .any(|k| matches!(k, cbor::Value::Text(s) if s == "attStmt")); + + assert!(has_fmt, "attestation object should have 'fmt' key"); + assert!( + has_auth_data, + "attestation object should have 'authData' key" + ); + assert!(has_att_stmt, "attestation object should have 'attStmt' key"); + } else { + panic!("attestation object should be a CBOR map"); + } + } + + #[test] + fn test_response_with_extensions() { + let mut response = create_test_response(); + + // Add some extension outputs + response.unsigned_extensions_output = MakeCredentialsResponseUnsignedExtensions { + cred_props: Some(CredentialPropsExtension { rk: Some(true) }), + hmac_create_secret: Some(true), + large_blob: None, + prf: Some(MakeCredentialPrfOutput { + enabled: Some(true), + }), + }; + + let request = create_test_request(); + let model = response.to_inner_model(&request).unwrap(); + + // Verify extension outputs + assert!(model.client_extension_results.cred_props.is_some()); + assert_eq!( + model + .client_extension_results + .cred_props + .as_ref() + .unwrap() + .rk, + Some(true) + ); + assert_eq!( + model.client_extension_results.hmac_create_secret, + Some(true) + ); + assert!(model.client_extension_results.prf.is_some()); + assert_eq!( + model.client_extension_results.prf.as_ref().unwrap().enabled, + Some(true) + ); + } } diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs index 382fd9e..b9536d3 100644 --- a/libwebauthn/src/ops/webauthn/mod.rs +++ b/libwebauthn/src/ops/webauthn/mod.rs @@ -6,6 +6,7 @@ mod timeout; use super::u2f::{RegisterRequest, SignRequest}; use crate::webauthn::CtapError; +pub use client_data::ClientData; pub use get_assertion::{ Assertion, Ctap2HMACGetSecretOutput, GetAssertionHmacOrPrfInput, GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrfOutput, @@ -13,7 +14,12 @@ pub use get_assertion::{ GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput, HMACGetSecretOutput, PRFValue, PrfInput, }; -pub use idl::{rpid::RelyingPartyId, Base64UrlString, WebAuthnIDL}; +pub use idl::{ + rpid::RelyingPartyId, AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON, + AuthenticatorAssertionResponseJSON, AuthenticatorAttestationResponseJSON, Base64UrlString, + JsonFormat, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDL, + WebAuthnIDLResponse, +}; pub use make_credential::{ CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy, MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionOutput, diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index 84373b4..cd84ecb 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -239,10 +239,10 @@ pub enum Ctap2UserVerificationOperation { mod tests { use crate::proto::ctap2::cbor; use crate::proto::ctap2::Ctap2PublicKeyCredentialDescriptor; + use serde_bytes::ByteBuf; use super::{Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2PublicKeyCredentialType}; use hex; - use serde_bytes::ByteBuf; use serde_cbor_2 as serde_cbor; #[test] diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 60996af..39da636 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -42,7 +42,7 @@ impl Ctap2GetAssertionOptions { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct PackedAttestationStmt { #[serde(rename = "alg")] pub algorithm: Ctap2COSEAlgorithmIdentifier, @@ -50,20 +50,21 @@ pub struct PackedAttestationStmt { #[serde(rename = "sig")] pub signature: ByteBuf, - #[serde(rename = "x5c")] + #[serde(rename = "x5c", skip_serializing_if = "Vec::is_empty", default)] pub certificates: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct FidoU2fAttestationStmt { #[serde(rename = "sig")] pub signature: ByteBuf, + /// Certificate chain as an array (spec requires array even for single cert). #[serde(rename = "x5c")] - pub certificate: ByteBuf, + pub certificates: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct TpmAttestationStmt { #[serde(rename = "ver")] pub version: String, @@ -74,7 +75,7 @@ pub struct TpmAttestationStmt { #[serde(rename = "sig")] pub signature: ByteBuf, - #[serde(rename = "x5c")] + #[serde(rename = "x5c", skip_serializing_if = "Vec::is_empty", default)] pub certificates: Vec, #[serde(rename = "certInfo")] @@ -84,13 +85,13 @@ pub struct TpmAttestationStmt { pub public_area: ByteBuf, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct AppleAnonymousAttestationStmt { - #[serde(rename = "x5c")] + #[serde(rename = "x5c", skip_serializing_if = "Vec::is_empty", default)] pub certificates: Vec, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum Ctap2AttestationStatement { PackedOrAndroid(PackedAttestationStmt), @@ -171,9 +172,10 @@ impl Ctap2GetAssertionRequest { impl From for Ctap2GetAssertionRequest { fn from(op: GetAssertionRequest) -> Self { + let client_data_hash = ByteBuf::from(op.client_data_hash()); Self { relying_party_id: op.relying_party_id, - client_data_hash: ByteBuf::from(op.hash), + client_data_hash, allow: op.allow, extensions: op.extensions.map(|ext| ext.into()), options: Some(Ctap2GetAssertionOptions { @@ -519,7 +521,11 @@ impl Ctap2GetAssertionResponseExtensions { }); let (hmac_get_secret, prf) = if let Some(decrypted) = decrypted_hmac { - match request.extensions.as_ref().and_then(|ext| ext.hmac_or_prf.as_ref()) { + match request + .extensions + .as_ref() + .and_then(|ext| ext.hmac_or_prf.as_ref()) + { None => (None, None), Some(GetAssertionHmacOrPrfInput::HmacGetSecret(..)) => (Some(decrypted), None), Some(GetAssertionHmacOrPrfInput::Prf(..)) => ( @@ -537,7 +543,11 @@ impl Ctap2GetAssertionResponseExtensions { }; // LargeBlobs was requested - let large_blob = match request.extensions.as_ref().and_then(|ext| ext.large_blob.as_ref()) { + let large_blob = match request + .extensions + .as_ref() + .and_then(|ext| ext.large_blob.as_ref()) + { None => None, Some(GetAssertionLargeBlobExtension::Read) => { Some(GetAssertionLargeBlobExtensionOutput { diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index 739dc9f..1bcb005 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -158,7 +158,7 @@ impl Ctap2MakeCredentialRequest { }; Ok(Ctap2MakeCredentialRequest { - hash: ByteBuf::from(req.hash.clone()), + hash: ByteBuf::from(req.client_data_hash()), relying_party: req.relying_party.clone(), user: req.user.clone(), algorithms: req.algorithms.clone(), diff --git a/libwebauthn/src/proto/ctap2/preflight.rs b/libwebauthn/src/proto/ctap2/preflight.rs index e6f339a..a116489 100644 --- a/libwebauthn/src/proto/ctap2/preflight.rs +++ b/libwebauthn/src/proto/ctap2/preflight.rs @@ -127,7 +127,8 @@ mod tests { let challenge: [u8; 32] = thread_rng().gen(); let make_credentials_request = MakeCredentialRequest { origin: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -151,7 +152,9 @@ mod tests { let challenge: [u8; 32] = thread_rng().gen(); let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_owned(), + cross_origin: None, allow: allow_list, user_verification: UserVerificationRequirement::Discouraged, extensions: None, diff --git a/libwebauthn/src/tests/basic_ctap2.rs b/libwebauthn/src/tests/basic_ctap2.rs index c383826..c7d36c2 100644 --- a/libwebauthn/src/tests/basic_ctap2.rs +++ b/libwebauthn/src/tests/basic_ctap2.rs @@ -38,8 +38,9 @@ async fn test_webauthn_basic_ctap2() { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { + challenge: Vec::from(challenge), origin: "example.org".to_owned(), - hash: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -63,7 +64,9 @@ async fn test_webauthn_basic_ctap2() { (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_string(), + cross_origin: None, allow: vec![credential], user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions::default()), diff --git a/libwebauthn/src/tests/prf.rs b/libwebauthn/src/tests/prf.rs index 95666b4..50f9f1f 100644 --- a/libwebauthn/src/tests/prf.rs +++ b/libwebauthn/src/tests/prf.rs @@ -108,7 +108,8 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { // Make Credentials ceremony let make_credentials_request = MakeCredentialRequest { origin: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + cross_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"), user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "mario.rossi", "Mario Rossi"), resident_key: Some(ResidentKeyRequirement::Discouraged), @@ -169,7 +170,9 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) { (&response.authenticator_data).try_into().unwrap(); let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_owned(), + cross_origin: None, allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Preferred, extensions: None, @@ -483,7 +486,9 @@ async fn run_success_test( ) { let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_owned(), + cross_origin: None, allow: vec![credential.clone()], user_verification: UserVerificationRequirement::Preferred, extensions: Some(GetAssertionRequestExtensions { @@ -548,7 +553,9 @@ async fn run_failed_test( ) { let get_assertion = GetAssertionRequest { relying_party_id: "example.org".to_owned(), - hash: Vec::from(challenge), + challenge: Vec::from(challenge), + origin: "example.org".to_owned(), + cross_origin: None, allow: credential.map(|x| vec![x.clone()]).unwrap_or_default(), user_verification: UserVerificationRequirement::Discouraged, extensions: Some(GetAssertionRequestExtensions { diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 134802a..57a632a 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -109,7 +109,7 @@ where if Self::supports_preflight() { if let Some(exclude_list) = &op.exclude { let filtered_exclude_list = - ctap2_preflight(self, exclude_list, &op.hash, &op.relying_party.id).await; + ctap2_preflight(self, exclude_list, &op.client_data_hash(), &op.relying_party.id).await; ctap2_request.exclude = Some(filtered_exclude_list); } } @@ -172,7 +172,7 @@ where if Self::supports_preflight() { let filtered_allow_list = - ctap2_preflight(self, &op.allow, &op.hash, &op.relying_party_id).await; + ctap2_preflight(self, &op.allow, &op.client_data_hash(), &op.relying_party_id).await; if filtered_allow_list.is_empty() && !op.allow.is_empty() { // We filtered out everything in preflight, meaning none of the allowed // credentials are present on this device. So we error out here diff --git a/libwebauthn/src/webauthn/pin_uv_auth_token.rs b/libwebauthn/src/webauthn/pin_uv_auth_token.rs index 0072bf7..4695796 100644 --- a/libwebauthn/src/webauthn/pin_uv_auth_token.rs +++ b/libwebauthn/src/webauthn/pin_uv_auth_token.rs @@ -481,7 +481,9 @@ mod test { Ctap2GetAssertionRequest::from_webauthn_request( &GetAssertionRequest { relying_party_id: String::from("example.com"), - hash: vec![9; 32], + challenge: vec![9; 32], + origin: String::from("example.com"), + cross_origin: None, allow: vec![], extensions, user_verification: UserVerificationRequirement::Preferred,