From 217a3a421cbcceb846e4ce212775b73bab3bbe12 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Fri, 26 Dec 2025 18:04:35 -0500 Subject: [PATCH 1/3] Use libwebauthn for JSON response serialization This commit migrates from custom JSON response serialization to libwebauthn's WebAuthnIDLResponse::to_inner_model() for both create credential (MakeCredential) and get credential (GetAssertion) responses. Changes: - Use libwebauthn's to_inner_model() to serialize responses, then modify the result to add transport and authenticator_attachment information that is known at the credential service level - Remove create_credential_request_try_into_ctap2's client_data_json return value (now extracted from the request by libwebauthn) - Remove get_credential_request_try_into_ctap2's client_data_json return value - Update gateway.rs to clone the request for response serialization - Remove unused modules: cbor.rs, cose.rs, serde/mod.rs - Simplify webauthn.rs to just re-exports from libwebauthn This removes ~800 lines of custom serialization code including: - CreatePublicKeyCredentialResponse and GetPublicKeyCredentialResponse - AttestationStatement enum and create_attestation_object function - All the extension output types (CredentialPropertiesOutput, etc.) - Custom CBOR writer for attestation object serialization - COSE key type helpers The response serialization now uses libwebauthn's implementation which: - Handles attestation object CBOR encoding - Handles all extension output serialization - Handles base64url encoding of binary fields - Produces WebAuthn Level 3 compliant JSON responses --- credentialsd/src/cbor.rs | 230 --------------- credentialsd/src/cose.rs | 82 ------ credentialsd/src/gateway/mod.rs | 46 +-- credentialsd/src/gateway/util.rs | 116 +++----- credentialsd/src/main.rs | 2 - credentialsd/src/webauthn.rs | 479 +------------------------------ 6 files changed, 72 insertions(+), 883 deletions(-) delete mode 100644 credentialsd/src/cbor.rs delete mode 100644 credentialsd/src/cose.rs diff --git a/credentialsd/src/cbor.rs b/credentialsd/src/cbor.rs deleted file mode 100644 index 3c3eef6..0000000 --- a/credentialsd/src/cbor.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::convert::TryInto; -use std::io::{Error, Write}; - -pub(crate) struct CborWriter<'a, W> { - writer: &'a mut W, -} - -impl CborWriter<'_, W> -where - W: Write, -{ - pub fn new(writer: &'_ mut W) -> CborWriter<'_, W> { - CborWriter { writer } - } - - pub fn write_bytes(&mut self, data: T) -> Result<(), Error> - where - T: AsRef<[u8]>, - { - self.write_cbor_value( - MajorType::ByteString, - data.as_ref().len().try_into().unwrap(), - Some(data.as_ref()), - )?; - Ok(()) - } - - pub fn write_number(&mut self, num: i128) -> Result<(), Error> { - const POSITIVE_INTEGER_MASK: u8 = 0b000_00000; - const NEGATIVE_INTEGER_MASK: u8 = 0b001_00000; - let (mask, num) = if num >= 0 { - (POSITIVE_INTEGER_MASK, num as u64) - } else { - (NEGATIVE_INTEGER_MASK, (-num - 1) as u64) - }; - if num < 24 { - let d: u8 = num.try_into().unwrap(); - self.writer.write_all(&[mask | d])?; - Ok(()) - } else if num < 2u64.pow(8) { - let d: u8 = num.try_into().unwrap(); - self.writer.write_all(&[mask | 24])?; - self.writer.write_all(&d.to_be_bytes())?; - Ok(()) - } else if num < 2u64.pow(16) { - let d: u16 = num.try_into().unwrap(); - self.writer.write_all(&[mask | 25])?; - self.writer.write_all(&d.to_be_bytes())?; - Ok(()) - } else if num < 2u64.pow(32) { - let d: u32 = num.try_into().unwrap(); - self.writer.write_all(&[mask | 26])?; - self.writer.write_all(&d.to_be_bytes())?; - Ok(()) - } else if num < 2u64.pow(64) { - let d: u64 = num; - self.writer.write_all(&[mask | 27])?; - self.writer.write_all(&d.to_be_bytes())?; - Ok(()) - } else { - Err(Error::new( - std::io::ErrorKind::InvalidInput, - "value too large".to_string(), - )) - } - } - - pub fn write_map_start(&mut self, len: usize) -> Result<(), Error> { - self.write_cbor_value(MajorType::Map, len as u64, None)?; - Ok(()) - } - - pub fn write_array_start(&mut self, len: usize) -> Result<(), Error> { - self.write_cbor_value(MajorType::Array, len as u64, None)?; - Ok(()) - } - - pub fn write_text(&mut self, text: &str) -> Result<(), Error> { - let data = text.as_bytes(); - self.write_cbor_value( - MajorType::TextString, - data.len().try_into().unwrap(), - Some(data), - )?; - Ok(()) - } - - fn write_cbor_value( - &mut self, - major_type: MajorType, - len: u64, - data: Option<&[u8]>, - ) -> Result<(), Error> { - let major_type_mask = match major_type { - MajorType::PositiveInteger => 0b000_00000, - MajorType::NegativeInteger => 0b001_00000, - MajorType::ByteString => 0b010_00000, - MajorType::TextString => 0b011_00000, - MajorType::Array => 0b100_00000, - MajorType::Map => 0b101_00000, - MajorType::Tag => 0b110_00000, - MajorType::Float => 0b111_00000, - }; - - let mut major_type_buf = [0; 9]; - if len < 24 { - let l: u8 = len.try_into().unwrap(); - self.writer.write_all(&[l | major_type_mask])?; - } else if len < 2u64.pow(8) { - let l: u8 = len.try_into().unwrap(); - major_type_buf[0] = 24u8 | major_type_mask; - major_type_buf[1..2].copy_from_slice(&l.to_be_bytes()); - self.writer.write_all(&major_type_buf[0..2])?; - } else if len < 2u64.pow(16) { - let l: u16 = len.try_into().unwrap(); - major_type_buf[0] = 25u8 | major_type_mask; - major_type_buf[1..3].copy_from_slice(&l.to_be_bytes()); - self.writer.write_all(&major_type_buf[0..3])?; - } else if len < 2u64.pow(32) { - let l: u32 = len.try_into().unwrap(); - major_type_buf[0] = 26u8 | major_type_mask; - major_type_buf[1..5].copy_from_slice(&l.to_be_bytes()); - self.writer.write_all(&major_type_buf[0..5])?; - } else if len < 2u64.pow(64) { - let l: u64 = len; - major_type_buf[0] = 27u8 | major_type_mask; - major_type_buf[1..9].copy_from_slice(&l.to_be_bytes()); - self.writer.write_all(&major_type_buf[0..9])?; - } else { - return Err(Error::new( - std::io::ErrorKind::Unsupported, - "Value too large".to_string(), - )); - } - if let Some(data) = data { - self.writer.write_all(data)?; - } - Ok(()) - } -} - -#[allow(dead_code)] -enum MajorType { - PositiveInteger, - NegativeInteger, - ByteString, - TextString, - Array, - Map, - Tag, - Float, -} - -#[cfg(test)] -mod tests { - use super::CborWriter; - - #[test] - fn write_bytes() { - let mut buf: Vec = Vec::with_capacity(16); - let mut cbor_writer = CborWriter::new(&mut buf); - let data: &[u8] = &[0x01, 0x23, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xff]; - cbor_writer.write_bytes(data).unwrap(); - assert_eq!( - buf, - &[ - 0b010_01001, - 0x01, - 0x23, - 0x34, - 0x56, - 0x78, - 0x9a, - 0xbc, - 0xde, - 0xff - ] - ); - } - - #[test] - fn write_bytes_over24() { - let mut buf: Vec = Vec::new(); - let mut cbor_writer = CborWriter::new(&mut buf); - let data = vec![0; 32]; - cbor_writer.write_bytes(data.clone()).unwrap(); - assert_eq!(&buf[0..2], &[0b010_11000, 32u8]); - assert_eq!(&buf[2..34], &data); - } - - #[test] - fn write_uint() { - let mut buf: Vec = Vec::with_capacity(16); - let mut cbor_writer = CborWriter::new(&mut buf); - cbor_writer.write_number(22_i128).unwrap(); - assert_eq!(buf, &[0b000_10110]); - } - - #[test] - fn write_number_u8() { - let mut buf: Vec = Vec::with_capacity(16); - let mut cbor_writer = CborWriter::new(&mut buf); - cbor_writer.write_number(500_i128).unwrap(); - assert_eq!(buf, &[0b000_11001, 0x01, 0xf4]); - } - - #[test] - fn write_negative_number() { - let mut buf: Vec = Vec::with_capacity(16); - let mut cbor_writer = CborWriter::new(&mut buf); - cbor_writer.write_number(-22_i128).unwrap(); - assert_eq!(buf, &[0b001_10101]); - } - - #[test] - fn write_negative_number_u8() { - let mut buf: Vec = Vec::with_capacity(16); - let mut cbor_writer = CborWriter::new(&mut buf); - cbor_writer.write_number(-500_i128).unwrap(); - assert_eq!(buf, &[0b001_11001, 0x01, 0xf3]); - } - - #[test] - fn write_map_start() { - let mut buf: Vec = Vec::with_capacity(3); - let mut cbor_writer = CborWriter::new(&mut buf); - cbor_writer.write_map_start(800).unwrap(); - assert_eq!(buf, &[0b101_11001, 0b0000_0011, 0b0010_0000,]); - } -} diff --git a/credentialsd/src/cose.rs b/credentialsd/src/cose.rs deleted file mode 100644 index d55c043..0000000 --- a/credentialsd/src/cose.rs +++ /dev/null @@ -1,82 +0,0 @@ -use libwebauthn::proto::ctap2::Ctap2COSEAlgorithmIdentifier; -use tracing::debug; - -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(i64)] -pub(super) enum CoseKeyType { - Es256P256, - EddsaEd25519, - RS256, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum CoseKeyAlgorithmIdentifier { - ES256, - EdDSA, - RS256, -} - -impl From for i64 { - fn from(value: CoseKeyAlgorithmIdentifier) -> Self { - match value { - CoseKeyAlgorithmIdentifier::ES256 => -7, - CoseKeyAlgorithmIdentifier::EdDSA => -8, - CoseKeyAlgorithmIdentifier::RS256 => -257, - } - } -} - -impl From for i128 { - fn from(value: CoseKeyAlgorithmIdentifier) -> Self { - match value { - CoseKeyAlgorithmIdentifier::ES256 => -7, - CoseKeyAlgorithmIdentifier::EdDSA => -8, - CoseKeyAlgorithmIdentifier::RS256 => -257, - } - } -} - -impl TryFrom for CoseKeyAlgorithmIdentifier { - type Error = Error; - - fn try_from(value: Ctap2COSEAlgorithmIdentifier) -> Result { - match value { - Ctap2COSEAlgorithmIdentifier::EDDSA => Ok(CoseKeyAlgorithmIdentifier::EdDSA), - Ctap2COSEAlgorithmIdentifier::ES256 => Ok(CoseKeyAlgorithmIdentifier::ES256), - Ctap2COSEAlgorithmIdentifier::TOPT => { - debug!("Unknown public key algorithm type: {:?}", value); - Err(Error::Unsupported) - } - Ctap2COSEAlgorithmIdentifier::Unknown => Err(Error::Unsupported), - } - } -} - -#[derive(Clone, Copy, PartialEq)] -pub enum CoseEllipticCurveIdentifier { - /// P-256 Elliptic Curve using uncompressed points. - P256, - /// P-384 Elliptic Curve using uncompressed points. - P384, - /// P-521 Elliptic Curve using uncompressed points. - P521, - /// Ed25519 Elliptic Curve using compressed points. - Ed25519, -} - -impl From for i64 { - fn from(value: CoseEllipticCurveIdentifier) -> Self { - match value { - CoseEllipticCurveIdentifier::P256 => 1, - CoseEllipticCurveIdentifier::P384 => 2, - CoseEllipticCurveIdentifier::P521 => 3, - CoseEllipticCurveIdentifier::Ed25519 => 6, - } - } -} - -#[derive(Debug)] -pub enum Error { - InvalidKey, - Unsupported, -} diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index ce1b64a..4e9a2bc 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -94,7 +94,7 @@ impl GatewayService { // - if RP ID is set, but origin's effective domain doesn't match // - query for related origins, if supported // - fail if not supported, or if RP ID doesn't match any related origins. - let (make_cred_request, client_data_json) = + let make_cred_request = create_credential_request_try_into_ctap2(&request, &request_environment) .inspect_err(|_| { tracing::error!( @@ -105,6 +105,7 @@ impl GatewayService { tracing::info!("No supported algorithms given in request. Rejecting request."); return Err(WebAuthnError::NotSupportedError); } + let make_cred_request_for_response = make_cred_request.clone(); let cred_request = CredentialRequest::CreatePublicKeyCredentialRequest(make_cred_request); @@ -114,15 +115,17 @@ impl GatewayService { .await?; if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { - let public_key_response = - create_credential_response_try_from_ctap2(&cred_response, client_data_json) - .map_err(|err| { - tracing::error!( - "Failed to parse credential response from authenticator: {err}" - ); - // Using NotAllowedError as a catch-all error. - WebAuthnError::NotAllowedError - })?; + let public_key_response = create_credential_response_try_from_ctap2( + &cred_response, + &make_cred_request_for_response, + ) + .map_err(|err| { + tracing::error!( + "Failed to parse credential response from authenticator: {err}" + ); + // Using NotAllowedError as a catch-all error. + WebAuthnError::NotAllowedError + })?; Ok(public_key_response.into()) } else { // TODO: is response safe to log here? @@ -154,13 +157,14 @@ impl GatewayService { // - if RP ID is set, but origin's effective domain doesn't match // - query for related origins, if supported // - fail if not supported, or if RP ID doesn't match any related origins. - let (get_cred_request, client_data_json) = + let get_cred_request = get_credential_request_try_into_ctap2(&request, &request_environment).map_err( |e| { tracing::error!("Could not parse passkey assertion request: {e:?}"); WebAuthnError::TypeError }, )?; + let get_cred_request_for_response = get_cred_request.clone(); let cred_request = CredentialRequest::GetPublicKeyCredentialRequest(get_cred_request); let response = self @@ -169,15 +173,17 @@ impl GatewayService { .await?; if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { - let public_key_response = - get_credential_response_try_from_ctap2(&cred_response, client_data_json) - .map_err(|err| { - tracing::error!( - "Failed to parse credential response from authenticator: {err}" - ); - // Using NotAllowedError as a catch-all error. - WebAuthnError::NotAllowedError - })?; + let public_key_response = get_credential_response_try_from_ctap2( + &cred_response, + &get_cred_request_for_response, + ) + .map_err(|err| { + tracing::error!( + "Failed to parse credential response from authenticator: {err}" + ); + // Using NotAllowedError as a catch-all error. + WebAuthnError::NotAllowedError + })?; Ok(public_key_response.into()) } else { // TODO: is response safe to log here? diff --git a/credentialsd/src/gateway/util.rs b/credentialsd/src/gateway/util.rs index 75c0945..f1fac4d 100644 --- a/credentialsd/src/gateway/util.rs +++ b/credentialsd/src/gateway/util.rs @@ -12,8 +12,8 @@ use credentialsd_common::{ use crate::model::{GetAssertionResponseInternal, MakeCredentialResponseInternal}; use crate::webauthn::{ - self, GetAssertionRequest, GetPublicKeyCredentialUnsignedExtensionsResponse, - MakeCredentialRequest, NavigationContext, Origin, RelyingPartyId, WebAuthnIDL, + GetAssertionRequest, MakeCredentialRequest, NavigationContext, Origin, RelyingPartyId, + WebAuthnIDL, WebAuthnIDLResponse, }; /// Reads the rpId from a create-credential request JSON (`rp.id`). @@ -70,7 +70,7 @@ fn peek_get_assertion_rp_id(request_json: &str) -> Result std::result::Result<(MakeCredentialRequest, String), WebAuthnError> { +) -> std::result::Result { let options = request.public_key.as_ref().ok_or_else(|| { tracing::info!("Invalid request: missing public_key"); WebAuthnError::NotSupportedError @@ -98,51 +98,34 @@ pub(super) fn create_credential_request_try_into_ctap2( NavigationContext::CrossOrigin(_) )); - let client_data_json = make_cred_request.client_data_json(); - - Ok((make_cred_request, client_data_json)) + Ok(make_cred_request) } /// Serializes a CTAP2 MakeCredentialResponse to WebAuthn JSON format. +/// +/// Uses libwebauthn's `WebAuthnIDLResponse::to_idl_model()` for serialization, then adds +/// transport and authenticator-attachment information that is known at the credential +/// service level. pub(super) fn create_credential_response_try_from_ctap2( response: &MakeCredentialResponseInternal, - client_data_json: String, + request: &MakeCredentialRequest, ) -> std::result::Result { - let auth_data = &response.ctap.authenticator_data; - let attested_credential = auth_data - .attested_credential - .as_ref() - .ok_or_else(|| "missing attested credential data".to_string())?; - - let unsigned_extensions = serde_json::to_string(&response.ctap.unsigned_extensions_output) - .map_err(|err| format!("failed to serialized unsigned extensions output: {err}")) - .unwrap(); - let authenticator_data_blob = auth_data - .to_response_bytes() - .map_err(|err| format!("failed to serialize authenticator data into bytes: {err}"))?; - let attestation_statement = (&response.ctap.attestation_statement) - .try_into() - .map_err(|_| "Could not serialize attestation statement".to_string())?; - let attestation_object = webauthn::create_attestation_object( - &authenticator_data_blob, - &attestation_statement, - response.ctap.enterprise_attestation.unwrap_or(false), - ) - .map_err(|_| "Failed to create attestation object".to_string())?; - - let registration_response_json = webauthn::CreatePublicKeyCredentialResponse::new( - attested_credential.credential_id.clone(), - attestation_object, - client_data_json, - Some(response.transport.clone()), - unsigned_extensions, - response.attachment_modality.clone(), - ) - .to_json(); - let response = CreatePublicKeyCredentialResponse { + let mut registration_json = response + .ctap + .to_idl_model(request) + .map_err(|err| format!("Failed to serialize registration response: {err}"))?; + + // TODO(libwebauthn#159): transports and authenticatorAttachment should be + // populated by libwebauthn once it has access to transport-level information. + registration_json.response.transports = response.transport.clone(); + registration_json.authenticator_attachment = Some(response.attachment_modality.clone()); + + let registration_response_json = serde_json::to_string(®istration_json) + .map_err(|err| format!("Failed to serialize registration response to JSON: {err}"))?; + + Ok(CreatePublicKeyCredentialResponse { registration_response_json, - }; - Ok(response) + }) } /// Parses a WebAuthn get credential request from D-Bus into a CTAP2 GetAssertionRequest. @@ -152,7 +135,7 @@ pub(super) fn create_credential_response_try_from_ctap2( pub(super) fn get_credential_request_try_into_ctap2( request: &GetCredentialRequest, request_environment: &NavigationContext, -) -> std::result::Result<(GetAssertionRequest, String), WebAuthnError> { +) -> std::result::Result { let options = request.public_key.as_ref().ok_or_else(|| { tracing::info!("Invalid request: no \"publicKey\" options specified."); WebAuthnError::NotSupportedError @@ -180,45 +163,30 @@ pub(super) fn get_credential_request_try_into_ctap2( NavigationContext::CrossOrigin(_) )); - let client_data_json = get_assertion_request.client_data_json(); - - Ok((get_assertion_request, client_data_json)) + Ok(get_assertion_request) } /// Serializes a CTAP2 GetAssertionResponse to WebAuthn JSON format. +/// +/// Uses libwebauthn's `WebAuthnIDLResponse::to_idl_model()` for serialization, then adds +/// authenticator-attachment information that is known at the credential service level. pub(super) fn get_credential_response_try_from_ctap2( response: &GetAssertionResponseInternal, - client_data_json: String, + request: &GetAssertionRequest, ) -> std::result::Result { - let authenticator_data_blob = response + let mut authentication_json = response .ctap - .authenticator_data - .to_response_bytes() - .map_err(|err| format!("Failed to parse authenticator data: {err}"))?; + .to_idl_model(request) + .map_err(|err| format!("Failed to serialize authentication response: {err}"))?; - let unsigned_extensions = response - .ctap - .unsigned_extensions_output - .as_ref() - .map(GetPublicKeyCredentialUnsignedExtensionsResponse::from); - - let authentication_response_json = webauthn::GetPublicKeyCredentialResponse::new( - client_data_json, - response - .ctap - .credential_id - .as_ref() - .map(|c| c.id.clone().into_vec()), - authenticator_data_blob, - response.ctap.signature.clone(), - response.ctap.user.as_ref().map(|u| u.id.clone().into_vec()), - response.attachment_modality.clone(), - unsigned_extensions, - ) - .to_json(); - - let response = GetPublicKeyCredentialResponse { + // TODO(libwebauthn#159): authenticatorAttachment should be populated by + // libwebauthn once it has access to transport-level information. + authentication_json.authenticator_attachment = Some(response.attachment_modality.clone()); + + let authentication_response_json = serde_json::to_string(&authentication_json) + .map_err(|err| format!("Failed to serialize authentication response to JSON: {err}"))?; + + Ok(GetPublicKeyCredentialResponse { authentication_response_json, - }; - Ok(response) + }) } diff --git a/credentialsd/src/main.rs b/credentialsd/src/main.rs index ef088c2..ba17f0d 100644 --- a/credentialsd/src/main.rs +++ b/credentialsd/src/main.rs @@ -1,5 +1,3 @@ -mod cbor; -mod cose; mod credential_service; mod dbus; mod gateway; diff --git a/credentialsd/src/webauthn.rs b/credentialsd/src/webauthn.rs index bb9a23a..8fc4f88 100644 --- a/credentialsd/src/webauthn.rs +++ b/credentialsd/src/webauthn.rs @@ -1,447 +1,12 @@ -use std::{fmt::Display, str::FromStr}; - -use base64::{self, engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use libwebauthn::proto::ctap2::Ctap2AttestationStatement; -use ring::digest; -use serde::Serialize; -use serde_json::json; -use tracing::debug; +//! WebAuthn types re-exported from libwebauthn, plus origin/navigation types +//! used to validate and route incoming requests. -use credentialsd_common::model::Operation; - -use crate::cose::CoseKeyAlgorithmIdentifier; +use std::{fmt::Display, str::FromStr}; pub use libwebauthn::ops::webauthn::{ - GetAssertionRequest, MakeCredentialRequest, RelyingPartyId, WebAuthnIDL, + GetAssertionRequest, MakeCredentialRequest, RelyingPartyId, WebAuthnIDL, WebAuthnIDLResponse, }; -#[derive(Debug)] -pub enum Error { - NotSupported, -} - -pub(crate) fn create_attestation_object( - authenticator_data: &[u8], - attestation_statement: &AttestationStatement, - _enterprise_attestation_possible: bool, -) -> Result, Error> { - let mut attestation_object = Vec::new(); - let mut cbor_writer = crate::cbor::CborWriter::new(&mut attestation_object); - cbor_writer.write_map_start(3).unwrap(); - cbor_writer.write_text("fmt").unwrap(); - match attestation_statement { - AttestationStatement::Packed { - algorithm, - signature, - certificates, - } => { - cbor_writer.write_text("packed").unwrap(); - cbor_writer.write_text("attStmt").unwrap(); - let len = if certificates.is_empty() { 2 } else { 3 }; - cbor_writer.write_map_start(len).unwrap(); - cbor_writer.write_text("alg").unwrap(); - cbor_writer.write_number((*algorithm).into()).unwrap(); - cbor_writer.write_text("sig").unwrap(); - cbor_writer.write_bytes(signature).unwrap(); - if !certificates.is_empty() { - cbor_writer.write_text("x5c").unwrap(); - cbor_writer.write_array_start(certificates.len()).unwrap(); - for cert in certificates.iter() { - cbor_writer.write_bytes(cert).unwrap(); - } - } - } - AttestationStatement::U2F { - signature, - certificates, - } => { - cbor_writer.write_text("fido-u2f").unwrap(); - cbor_writer.write_text("attStmt").unwrap(); - cbor_writer.write_map_start(2).unwrap(); - cbor_writer.write_text("x5c").unwrap(); - cbor_writer.write_array_start(certificates.len()).unwrap(); - for cert in certificates.iter() { - cbor_writer.write_bytes(cert).unwrap(); - } - cbor_writer.write_text("sig").unwrap(); - cbor_writer.write_bytes(signature).unwrap(); - } - AttestationStatement::None => { - cbor_writer.write_text("none").unwrap(); - cbor_writer.write_text("attStmt").unwrap(); - cbor_writer.write_map_start(0).unwrap(); - } - }; - - cbor_writer.write_text("authData").unwrap(); - cbor_writer.write_bytes(authenticator_data).unwrap(); - - Ok(attestation_object) -} - -#[derive(Debug, PartialEq)] -pub(crate) enum AttestationStatement { - None, - U2F { - signature: Vec, - certificates: Vec>, - }, - Packed { - algorithm: CoseKeyAlgorithmIdentifier, - signature: Vec, - certificates: Vec>, - }, -} - -impl TryFrom<&Ctap2AttestationStatement> for AttestationStatement { - type Error = Error; - - fn try_from(value: &Ctap2AttestationStatement) -> Result { - match value { - Ctap2AttestationStatement::None(_) => Ok(AttestationStatement::None), - Ctap2AttestationStatement::PackedOrAndroid(att_stmt) => { - let alg = att_stmt - .algorithm - .try_into() - .map_err(|_| Error::NotSupported)?; - Ok(Self::Packed { - algorithm: alg, - signature: att_stmt.signature.as_ref().to_vec(), - certificates: att_stmt - .certificates - .iter() - .map(|c| c.as_ref().to_vec()) - .collect(), - }) - } - Ctap2AttestationStatement::FidoU2F(att_stmt) => Ok(Self::U2F { - signature: att_stmt.signature.as_ref().to_vec(), - certificates: att_stmt - .certificates - .iter() - .map(|c| c.as_ref().to_vec()) - .collect(), - }), - _ => { - debug!("Unsupported attestation type: {:?}", value); - Err(Error::NotSupported) - } - } - } -} - -pub struct CreatePublicKeyCredentialResponse { - /// Raw bytes of credential ID. - raw_id: Vec, - - response: AttestationResponse, - - /// JSON string of extension output - extensions: String, - - /// If the device used is builtin ("platform") or removable ("cross-platform", aka "roaming") - attachment_modality: String, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CredentialPropertiesOutput { - /// This OPTIONAL property, known abstractly as the resident key credential property (i.e., client-side discoverable credential property), is a Boolean value indicating whether the PublicKeyCredential returned as a result of a registration ceremony is a client-side discoverable credential. If rk is true, the credential is a discoverable credential. if rk is false, the credential is a server-side credential. If rk is not present, it is not known whether the credential is a discoverable credential or a server-side credential. - #[serde(skip_serializing_if = "Option::is_none")] - pub rk: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationExtensionsLargeBlobOutputs { - /// true if, and only if, the created credential supports storing large blobs. Only present in registration outputs. - #[serde(skip_serializing_if = "Option::is_none")] - pub supported: Option, - /// The opaque byte string that was associated with the credential identified by rawId. Only valid if read was true. - #[serde(skip_serializing_if = "Option::is_none")] - pub blob: Option>, - /// A boolean that indicates that the contents of write were successfully stored on the authenticator, associated with the specified credential. - #[serde(skip_serializing_if = "Option::is_none")] - pub written: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationExtensionsPRFValues { - pub first: Vec, - pub second: Option>, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthenticationExtensionsPRFOutputs { - /// true if, and only if, the one or two PRFs are available for use with the created credential. This is only reported during registration and is not present in the case of authentication. - #[serde(skip_serializing_if = "Option::is_none")] - pub enabled: Option, - /// The results of evaluating the PRF for the inputs given in eval or evalByCredential. Outputs may not be available during registration; see comments in eval. - #[serde(skip_serializing_if = "Option::is_none")] - pub results: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreatePublicKeyExtensionsResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub cred_props: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub large_blob: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cred_protect: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub min_pin_length: Option, -} - -/// Returned from a creation of a new public key credential. -pub struct AttestationResponse { - /// clientDataJSON. - client_data_json: String, - - /// Bytes containing authenticator data and an attestation statement. - attestation_object: Vec, - - /// Transports that the authenticator is believed to support, or an - /// empty sequence if the information is unavailable. - /// - /// Should be one of - /// - `usb` - /// - `nfc` - /// - `ble` - /// - `internal` - /// - /// but others may be specified. - transports: Vec, -} - -impl CreatePublicKeyCredentialResponse { - pub fn new( - id: Vec, - attestation_object: Vec, - client_data_json: String, - transports: Option>, - extension_output_json: String, - attachment_modality: String, - ) -> Self { - Self { - raw_id: id, - response: AttestationResponse { - client_data_json, - attestation_object, - transports: transports.unwrap_or_default(), - }, - extensions: extension_output_json, - attachment_modality, - } - } - - pub fn get_id(&self) -> String { - URL_SAFE_NO_PAD.encode(&self.raw_id) - } - - pub fn to_json(&self) -> String { - let response = json!({ - "clientDataJSON": URL_SAFE_NO_PAD.encode(self.response.client_data_json.as_bytes()), - "attestationObject": URL_SAFE_NO_PAD.encode(&self.response.attestation_object), - "transports": self.response.transports, - }); - let extensions: serde_json::Value = serde_json::from_str(&self.extensions) - .expect("Extensions json to be formatted properly"); - let output = json!({ - "id": self.get_id(), - "rawId": self.get_id(), - "response": response, - "authenticatorAttachment": self.attachment_modality, - "clientExtensionResults": extensions, - }); - output.to_string() - } -} - -pub struct GetPublicKeyCredentialResponse { - /// clientDataJSON. - pub(crate) client_data_json: String, - - /// Raw bytes of credential ID. Not returned if only one descriptor was - /// passed in the allow credentials list. - pub(crate) raw_id: Option>, - - /// Encodes contextual bindings made by the authenticator. These bindings - /// are controlled by the authenticator itself. - pub(crate) authenticator_data: Vec, - - pub(crate) signature: Vec, - - /// The user handle associated when this public key credential source was - /// created. This item is nullable, however user handle MUST always be - /// populated for discoverable credentials. - pub(crate) user_handle: Option>, - - /// Whether the used device is "cross-platform" (aka "roaming", i.e.: can be - /// removed from the platform) or is built-in ("platform"). - pub(crate) attachment_modality: String, - - /// Unsigned extension output - /// Unlike CreatePublicKey, we can't use a directly serialized JSON string here, - /// because we have to encode/decode the byte arrays for the JavaScript-communication - pub(crate) extensions: Option, -} - -#[derive(Clone, Debug, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GetPublicKeyCredentialHMACGetSecretOutput { - // base64-encoded bytestring - pub output1: String, - #[serde(skip_serializing_if = "Option::is_none")] - // base64-encoded bytestring - pub output2: Option, -} - -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] -pub struct GetPublicKeyCredentialLargeBlobOutput { - #[serde(skip_serializing_if = "Option::is_none")] - // base64-encoded bytestring - pub blob: Option, - // Not yet supported - // #[serde(skip_serializing_if = "Option::is_none")] - // pub written: Option, -} - -#[derive(Debug, Default, Clone, Serialize)] -pub struct GetPublicKeyCredentialPrfOutput { - #[serde(skip_serializing_if = "Option::is_none")] - pub results: Option, -} - -#[derive(Debug, Default, Clone, Serialize)] -pub struct GetPublicKeyCredentialPRFValue { - // base64-encoded bytestring - pub first: String, - #[serde(skip_serializing_if = "Option::is_none")] - // base64-encoded bytestring - pub second: Option, -} - -#[derive(Debug, Default, Clone, Serialize)] -pub struct GetPublicKeyCredentialUnsignedExtensionsResponse { - pub hmac_get_secret: Option, - pub large_blob: Option, - pub prf: Option, -} - -// Unlike CreatePublicKey, for GetPublicKey, we have a lot of Byte arrays, -// so we need a lot of de/constructions, instead of serializing it directly -impl From<&libwebauthn::ops::webauthn::GetAssertionResponseUnsignedExtensions> - for GetPublicKeyCredentialUnsignedExtensionsResponse -{ - fn from(value: &libwebauthn::ops::webauthn::GetAssertionResponseUnsignedExtensions) -> Self { - Self { - hmac_get_secret: value.hmac_get_secret.as_ref().map(|x| { - GetPublicKeyCredentialHMACGetSecretOutput { - output1: URL_SAFE_NO_PAD.encode(x.output1), - output2: x.output2.map(|output2| URL_SAFE_NO_PAD.encode(output2)), - } - }), - large_blob: value - .large_blob - .as_ref() - .map(|x| GetPublicKeyCredentialLargeBlobOutput { - blob: x.blob.as_ref().map(|blob| URL_SAFE_NO_PAD.encode(blob)), - }), - prf: value.prf.as_ref().map(|x| GetPublicKeyCredentialPrfOutput { - results: x - .results - .as_ref() - .map(|results| GetPublicKeyCredentialPRFValue { - first: URL_SAFE_NO_PAD.encode(results.first), - second: results.second.map(|second| URL_SAFE_NO_PAD.encode(second)), - }), - }), - } - } -} - -impl GetPublicKeyCredentialResponse { - pub(crate) fn new( - client_data_json: String, - id: Option>, - authenticator_data: Vec, - signature: Vec, - user_handle: Option>, - attachment_modality: String, - extensions: Option, - ) -> Self { - Self { - client_data_json, - raw_id: id, - authenticator_data, - signature, - user_handle, - attachment_modality, - extensions, - } - } - pub fn to_json(&self) -> String { - let response = json!({ - "clientDataJSON": URL_SAFE_NO_PAD.encode(self.client_data_json.as_bytes()), - "authenticatorData": URL_SAFE_NO_PAD.encode(&self.authenticator_data), - "signature": URL_SAFE_NO_PAD.encode(&self.signature), - "userHandle": self.user_handle.as_ref().map(|h| URL_SAFE_NO_PAD.encode(h)) - }); - // TODO: I believe this optional since authenticators may omit sending the credential ID if it was - // unambiguously specified in the request. As a convenience, we should - // always return a credential ID, even if the authenticator doesn't. - // This means we'll have to remember the ID on the request if the allow-list has exactly one - // credential descriptor. This should probably be done in libwebauthn. - let id = self.raw_id.as_ref().map(|id| URL_SAFE_NO_PAD.encode(id)); - - let output = json!({ - "id": id, - "rawId": id, - "authenticatorAttachment": self.attachment_modality, - "response": response, - "clientExtensionResults": self.extensions, - }); - output.to_string() - } -} - -pub fn create_client_data_hash(json: &str) -> Vec { - digest::digest(&digest::SHA256, json.as_bytes()) - .as_ref() - .to_owned() -} - -pub fn format_client_data_json( - op: Operation, - challenge: &str, - origin: &NavigationContext, -) -> String { - let op_str = match op { - Operation::Create => "webauthn.create", - Operation::Get => "webauthn.get", - }; - let mut client_data_json = format!( - r#"{{"type":"{}","challenge":"{}","origin":"{}""#, - op_str, - challenge, - origin.origin() - ); - if let Some(top_origin) = origin.top_origin() { - client_data_json.push_str(&format!( - r#","crossOrigin":true,"topOrigin":"{top_origin}"}}"# - )); - } else { - client_data_json.push_str(r#","crossOrigin":false}"#); - } - client_data_json -} - /// An application ID conforming to the /// [XDG desktop entry syntax][xdg-desktop-entry-name]. /// @@ -459,8 +24,6 @@ impl FromStr for AppId { type Err = (); fn from_str(s: &str) -> Result { - // This algorithm could be made more efficient, but this is fairly readable. - // begins with a letter match s.chars().nth(0) { Some(c) if c.is_ascii_alphabetic() => {} @@ -590,14 +153,6 @@ impl NavigationContext { NavigationContext::CrossOrigin((origin, _)) => origin, } } - - /// Retrieves the top origin from the context, if any. - pub(crate) fn top_origin(&self) -> Option<&Origin> { - match self { - NavigationContext::SameOrigin(_) => None, - NavigationContext::CrossOrigin((_, ref top_origin)) => Some(top_origin), - } - } } #[derive(Debug)] @@ -623,32 +178,6 @@ impl Display for OriginParseError { mod tests { use crate::webauthn::{Origin, OriginParseError}; - use super::{format_client_data_json, NavigationContext, Operation}; - #[test] - fn test_same_origin_client_data_json_str() { - let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":false}"#; - let json = format_client_data_json( - Operation::Create, - "abcd", - &NavigationContext::SameOrigin("https://example.com".parse().unwrap()), - ); - assert_eq!(expected, json); - } - - #[test] - fn test_cross_origin_client_data_json_str() { - let expected = r#"{"type":"webauthn.create","challenge":"abcd","origin":"https://example.com","crossOrigin":true,"topOrigin":"https://example.org"}"#; - let json = format_client_data_json( - Operation::Create, - "abcd", - &NavigationContext::CrossOrigin(( - "https://example.com".parse().unwrap(), - "https://example.org".parse().unwrap(), - )), - ); - assert_eq!(expected, json); - } - fn check_https_origin(origin: &str, expected_host: &str, expected_port: Option) { let Origin::Https { host, port }: Origin = origin.parse().unwrap() else { panic!("Not an https origin"); From fbe497e043eb38b849637a2bd9634e5e875acb0d Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Wed, 18 Feb 2026 21:59:53 +0000 Subject: [PATCH 2/3] Feedback: remove unnecessary clone --- credentialsd/src/gateway/mod.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index 4e9a2bc..cfbfae0 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -105,9 +105,8 @@ impl GatewayService { tracing::info!("No supported algorithms given in request. Rejecting request."); return Err(WebAuthnError::NotSupportedError); } - let make_cred_request_for_response = make_cred_request.clone(); let cred_request = - CredentialRequest::CreatePublicKeyCredentialRequest(make_cred_request); + CredentialRequest::CreatePublicKeyCredentialRequest(make_cred_request.clone()); let response = self .request_controller @@ -117,7 +116,7 @@ impl GatewayService { if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { let public_key_response = create_credential_response_try_from_ctap2( &cred_response, - &make_cred_request_for_response, + &make_cred_request, ) .map_err(|err| { tracing::error!( @@ -164,8 +163,8 @@ impl GatewayService { WebAuthnError::TypeError }, )?; - let get_cred_request_for_response = get_cred_request.clone(); - let cred_request = CredentialRequest::GetPublicKeyCredentialRequest(get_cred_request); + let cred_request = + CredentialRequest::GetPublicKeyCredentialRequest(get_cred_request.clone()); let response = self .request_controller @@ -175,7 +174,7 @@ impl GatewayService { if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { let public_key_response = get_credential_response_try_from_ctap2( &cred_response, - &get_cred_request_for_response, + &get_cred_request, ) .map_err(|err| { tracing::error!( From c2759d66d807932d0e98520bb98997f46d3c928f Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sat, 9 May 2026 00:20:50 +0100 Subject: [PATCH 3/3] Apply cargo fmt --- credentialsd/src/gateway/mod.rs | 40 +++++++++++++++------------------ 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/credentialsd/src/gateway/mod.rs b/credentialsd/src/gateway/mod.rs index cfbfae0..9755ea3 100644 --- a/credentialsd/src/gateway/mod.rs +++ b/credentialsd/src/gateway/mod.rs @@ -114,17 +114,15 @@ impl GatewayService { .await?; if let CredentialResponse::CreatePublicKeyCredentialResponse(cred_response) = response { - let public_key_response = create_credential_response_try_from_ctap2( - &cred_response, - &make_cred_request, - ) - .map_err(|err| { - tracing::error!( - "Failed to parse credential response from authenticator: {err}" - ); - // Using NotAllowedError as a catch-all error. - WebAuthnError::NotAllowedError - })?; + let public_key_response = + create_credential_response_try_from_ctap2(&cred_response, &make_cred_request) + .map_err(|err| { + tracing::error!( + "Failed to parse credential response from authenticator: {err}" + ); + // Using NotAllowedError as a catch-all error. + WebAuthnError::NotAllowedError + })?; Ok(public_key_response.into()) } else { // TODO: is response safe to log here? @@ -172,17 +170,15 @@ impl GatewayService { .await?; if let CredentialResponse::GetPublicKeyCredentialResponse(cred_response) = response { - let public_key_response = get_credential_response_try_from_ctap2( - &cred_response, - &get_cred_request, - ) - .map_err(|err| { - tracing::error!( - "Failed to parse credential response from authenticator: {err}" - ); - // Using NotAllowedError as a catch-all error. - WebAuthnError::NotAllowedError - })?; + let public_key_response = + get_credential_response_try_from_ctap2(&cred_response, &get_cred_request) + .map_err(|err| { + tracing::error!( + "Failed to parse credential response from authenticator: {err}" + ); + // Using NotAllowedError as a catch-all error. + WebAuthnError::NotAllowedError + })?; Ok(public_key_response.into()) } else { // TODO: is response safe to log here?