Skip to content

Commit 89bb140

Browse files
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.
1 parent 4151e17 commit 89bb140

5 files changed

Lines changed: 52 additions & 0 deletions

File tree

libwebauthn/src/fido.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,24 @@ pub struct AuthenticatorData<T> {
100100
pub signature_count: u32,
101101
pub attested_credential: Option<AttestedCredentialData>,
102102
pub extensions: Option<T>,
103+
/// Raw authData bytes as received from the device, preserved verbatim so
104+
/// the RP's signature over authData stays valid. `None` for authData the
105+
/// platform synthesizes (e.g. the U2F upgrade path), which is rebuilt from
106+
/// the fields above.
107+
pub raw: Option<Vec<u8>>,
103108
}
104109

105110
impl<T> AuthenticatorData<T>
106111
where
107112
T: Clone + Serialize,
108113
{
109114
pub fn to_response_bytes(&self) -> Result<Vec<u8>, Error> {
115+
// Return the device's authData verbatim. Re-encoding from the parsed
116+
// fields would reorder or drop unmodeled extensions, invalidating the
117+
// authenticator's signature over these exact bytes.
118+
if let Some(raw) = &self.raw {
119+
return Ok(raw.clone());
120+
}
110121
// Name | Length
111122
// -----------------------------------
112123
// rpIdHash | 32
@@ -284,6 +295,7 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for AuthenticatorData<T> {
284295
signature_count,
285296
attested_credential,
286297
extensions,
298+
raw: Some(data.to_vec()),
287299
})
288300
}
289301
}
@@ -349,6 +361,7 @@ mod tests {
349361
signature_count,
350362
attested_credential: Some(attested_credential.clone()),
351363
extensions: Some(extensions.clone()),
364+
raw: None,
352365
};
353366
let webauthn_auth_data = auth_data.to_response_bytes().unwrap();
354367
assert_eq!(rp_id_hash, &webauthn_auth_data[..32]);
@@ -432,6 +445,7 @@ mod tests {
432445
signature_count: 1,
433446
attested_credential: Some(attested_credential),
434447
extensions: None,
448+
raw: None,
435449
};
436450

437451
let bytes = auth_data.to_response_bytes().unwrap();
@@ -445,4 +459,37 @@ mod tests {
445459
crate::proto::ctap2::Ctap2COSEAlgorithmIdentifier::RS256
446460
);
447461
}
462+
463+
#[test]
464+
fn to_response_bytes_preserves_extensions_verbatim() {
465+
// The authenticator signs over the exact authData bytes, including the
466+
// extensions block. Re-encoding from the typed struct drops keys it does
467+
// not model (and may reorder the rest), which would break relying-party
468+
// signature verification. The bytes must round-trip unchanged.
469+
use crate::proto::ctap2::Ctap2MakeCredentialsResponseExtensions;
470+
471+
let flags = AuthenticatorDataFlags::USER_PRESENT | AuthenticatorDataFlags::EXTENSION_DATA;
472+
let mut input = [0x11u8; 32].to_vec();
473+
input.push(flags.bits());
474+
input.extend_from_slice(&[0x00, 0x00, 0x00, 0x07]); // signCount
475+
476+
// CBOR: { "credBlob": true, "thirdPartyPayment": true }. The second key
477+
// is unmodeled, so the typed struct drops it on re-encode.
478+
input.extend_from_slice(&[
479+
0xA2, // map(2)
480+
0x68, b'c', b'r', b'e', b'd', b'B', b'l', b'o', b'b', 0xF5, // "credBlob": true
481+
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',
482+
b'm', b'e', b'n', b't', 0xF5, // "thirdPartyPayment": true
483+
]);
484+
485+
let wrapped = cbor::to_vec(&ByteBuf::from(input.clone())).unwrap();
486+
let parsed: AuthenticatorData<Ctap2MakeCredentialsResponseExtensions> =
487+
cbor::from_slice(&wrapped).unwrap();
488+
489+
assert_eq!(
490+
parsed.to_response_bytes().unwrap(),
491+
input,
492+
"authenticatorData must be preserved byte-for-byte"
493+
);
494+
}
448495
}

libwebauthn/src/ops/u2f.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ impl UpgradableResponse<MakeCredentialResponse, MakeCredentialRequest> for Regis
143143
signature_count,
144144
attested_credential: Some(attested_cred_data),
145145
extensions: None,
146+
raw: None,
146147
};
147148

148149
// Let attestationStatement be a CBOR map (see "attStmtTemplate" in Generating an Attestation Object [WebAuthn])
@@ -201,6 +202,7 @@ impl UpgradableResponse<GetAssertionResponse, SignRequest> for SignResponse {
201202
signature_count,
202203
attested_credential: None,
203204
extensions: None,
205+
raw: None,
204206
};
205207

206208
// Let authenticatorGetAssertionResponse be a CBOR map with the following keys whose values are as follows: [..]

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,7 @@ mod tests {
12151215
signature_count: 1,
12161216
attested_credential: None,
12171217
extensions: None,
1218+
raw: None,
12181219
};
12191220

12201221
Assertion {

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,7 @@ mod tests {
13821382
signature_count: 0,
13831383
attested_credential: Some(attested_credential),
13841384
extensions: None,
1385+
raw: None,
13851386
};
13861387

13871388
MakeCredentialResponse {

libwebauthn/src/proto/ctap2/model/get_assertion.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ mod tests {
569569
signature_count: 0,
570570
attested_credential: None,
571571
extensions: None,
572+
raw: None,
572573
},
573574
signature: ByteBuf::from(vec![0u8; 32]),
574575
user: None,

0 commit comments

Comments
 (0)