Skip to content

Commit 97eaaf2

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 76512c4 commit 97eaaf2

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
@@ -1213,6 +1213,7 @@ mod tests {
12131213
signature_count: 1,
12141214
attested_credential: None,
12151215
extensions: None,
1216+
raw: None,
12161217
};
12171218

12181219
Assertion {

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,7 @@ mod tests {
13881388
signature_count: 0,
13891389
attested_credential: Some(attested_credential),
13901390
extensions: None,
1391+
raw: None,
13911392
};
13921393

13931394
MakeCredentialResponse {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ mod tests {
563563
signature_count: 0,
564564
attested_credential: None,
565565
extensions: None,
566+
raw: None,
566567
},
567568
signature: ByteBuf::from(vec![0u8; 32]),
568569
user: None,

0 commit comments

Comments
 (0)