From 97eaaf2eebe28a31ecf87a1b0a89a53bd79eac63 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 7 Jun 2026 12:06:57 +0100 Subject: [PATCH] fix(ctap2): preserve raw authenticatorData bytes The authenticator signs over the exact authData bytes, including the extensions block. Rebuilding authData from the parsed struct could drop unmodeled extensions or reorder keys, breaking the relying party's signature verification. Keep the device bytes and return them unchanged. Platform-synthesized U2F authData still rebuilds from the fields. --- libwebauthn/src/fido.rs | 47 +++++++++++++++++++ libwebauthn/src/ops/u2f.rs | 2 + libwebauthn/src/ops/webauthn/get_assertion.rs | 1 + .../src/ops/webauthn/make_credential.rs | 1 + .../src/proto/ctap2/model/get_assertion.rs | 1 + 5 files changed, 52 insertions(+) diff --git a/libwebauthn/src/fido.rs b/libwebauthn/src/fido.rs index 1d5f2cf3..bbb5c3c8 100644 --- a/libwebauthn/src/fido.rs +++ b/libwebauthn/src/fido.rs @@ -100,6 +100,11 @@ pub struct AuthenticatorData { pub signature_count: u32, pub attested_credential: Option, pub extensions: Option, + /// Raw authData bytes as received from the device, preserved verbatim so + /// the RP's signature over authData stays valid. `None` for authData the + /// platform synthesizes (e.g. the U2F upgrade path), which is rebuilt from + /// the fields above. + pub raw: Option>, } impl AuthenticatorData @@ -107,6 +112,12 @@ where T: Clone + Serialize, { pub fn to_response_bytes(&self) -> Result, Error> { + // Return the device's authData verbatim. Re-encoding from the parsed + // fields would reorder or drop unmodeled extensions, invalidating the + // authenticator's signature over these exact bytes. + if let Some(raw) = &self.raw { + return Ok(raw.clone()); + } // Name | Length // ----------------------------------- // rpIdHash | 32 @@ -284,6 +295,7 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for AuthenticatorData { signature_count, attested_credential, extensions, + raw: Some(data.to_vec()), }) } } @@ -349,6 +361,7 @@ mod tests { signature_count, attested_credential: Some(attested_credential.clone()), extensions: Some(extensions.clone()), + raw: None, }; let webauthn_auth_data = auth_data.to_response_bytes().unwrap(); assert_eq!(rp_id_hash, &webauthn_auth_data[..32]); @@ -432,6 +445,7 @@ mod tests { signature_count: 1, attested_credential: Some(attested_credential), extensions: None, + raw: None, }; let bytes = auth_data.to_response_bytes().unwrap(); @@ -445,4 +459,37 @@ mod tests { crate::proto::ctap2::Ctap2COSEAlgorithmIdentifier::RS256 ); } + + #[test] + fn to_response_bytes_preserves_extensions_verbatim() { + // The authenticator signs over the exact authData bytes, including the + // extensions block. Re-encoding from the typed struct drops keys it does + // not model (and may reorder the rest), which would break relying-party + // signature verification. The bytes must round-trip unchanged. + use crate::proto::ctap2::Ctap2MakeCredentialsResponseExtensions; + + let flags = AuthenticatorDataFlags::USER_PRESENT | AuthenticatorDataFlags::EXTENSION_DATA; + let mut input = [0x11u8; 32].to_vec(); + input.push(flags.bits()); + input.extend_from_slice(&[0x00, 0x00, 0x00, 0x07]); // signCount + + // CBOR: { "credBlob": true, "thirdPartyPayment": true }. The second key + // is unmodeled, so the typed struct drops it on re-encode. + input.extend_from_slice(&[ + 0xA2, // map(2) + 0x68, b'c', b'r', b'e', b'd', b'B', b'l', b'o', b'b', 0xF5, // "credBlob": true + 0x71, b't', b'h', b'i', b'r', b'd', b'P', b'a', b'r', b't', b'y', b'P', b'a', b'y', + b'm', b'e', b'n', b't', 0xF5, // "thirdPartyPayment": true + ]); + + let wrapped = cbor::to_vec(&ByteBuf::from(input.clone())).unwrap(); + let parsed: AuthenticatorData = + cbor::from_slice(&wrapped).unwrap(); + + assert_eq!( + parsed.to_response_bytes().unwrap(), + input, + "authenticatorData must be preserved byte-for-byte" + ); + } } diff --git a/libwebauthn/src/ops/u2f.rs b/libwebauthn/src/ops/u2f.rs index cb45c78d..62e51cae 100644 --- a/libwebauthn/src/ops/u2f.rs +++ b/libwebauthn/src/ops/u2f.rs @@ -143,6 +143,7 @@ impl UpgradableResponse for Regis signature_count, attested_credential: Some(attested_cred_data), extensions: None, + raw: None, }; // Let attestationStatement be a CBOR map (see "attStmtTemplate" in Generating an Attestation Object [WebAuthn]) @@ -201,6 +202,7 @@ impl UpgradableResponse for SignResponse { signature_count, attested_credential: None, extensions: None, + raw: None, }; // Let authenticatorGetAssertionResponse be a CBOR map with the following keys whose values are as follows: [..] diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 49923763..2deaf925 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -1213,6 +1213,7 @@ mod tests { signature_count: 1, attested_credential: None, extensions: None, + raw: None, }; Assertion { diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 43cf0572..f368a813 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -1388,6 +1388,7 @@ mod tests { signature_count: 0, attested_credential: Some(attested_credential), extensions: None, + raw: None, }; MakeCredentialResponse { diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index f7039bc4..cba6e6dc 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -563,6 +563,7 @@ mod tests { signature_count: 0, attested_credential: None, extensions: None, + raw: None, }, signature: ByteBuf::from(vec![0u8; 32]), user: None,