Skip to content

Commit 6db7939

Browse files
feat(webauthn): scrub attestation for attestation=none conveyance
WebAuthn L3 §5.4.1 requires the platform to anonymise the attestation object when the relying party requests attestation conveyance "none": replace fmt with "none", attStmt with an empty map, and zero the AAGUID in the authenticator data. libwebauthn parsed the conveyance preference but discarded it, forwarding the authenticator's real format, attStmt, and AAGUID verbatim. This honours the preference at response assembly time. The WebAuthn IDL default of "none" is applied at the IDL boundary, so JSON requests that omit attestation are scrubbed too. Requests built directly with attestation None pass through.
1 parent c3f8597 commit 6db7939

11 files changed

Lines changed: 172 additions & 16 deletions

File tree

libwebauthn-tests/tests/basic_ctap2.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ async fn test_webauthn_basic_ctap2() {
4646
resident_key: Some(ResidentKeyRequirement::Discouraged),
4747
user_verification: UserVerificationRequirement::Preferred,
4848
algorithms: vec![Ctap2CredentialType::default()],
49+
attestation: None,
4950
exclude: None,
5051
extensions: None,
5152
timeout: TIMEOUT,

libwebauthn-tests/tests/large_blob.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async fn test_webauthn_large_blob_read_returns_planted_blob() {
5353
resident_key: Some(ResidentKeyRequirement::Required),
5454
user_verification: UserVerificationRequirement::Discouraged,
5555
algorithms: vec![Ctap2CredentialType::default()],
56+
attestation: None,
5657
exclude: None,
5758
extensions: Some(MakeCredentialsRequestExtensions {
5859
large_blob: Some(MakeCredentialLargeBlobExtensionInput {
@@ -245,6 +246,7 @@ async fn register_with_large_blob(
245246
resident_key: Some(ResidentKeyRequirement::Required),
246247
user_verification: UserVerificationRequirement::Discouraged,
247248
algorithms: vec![Ctap2CredentialType::default()],
249+
attestation: None,
248250
exclude: None,
249251
extensions: Some(MakeCredentialsRequestExtensions {
250252
large_blob: Some(MakeCredentialLargeBlobExtensionInput {

libwebauthn-tests/tests/preflight.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async fn make_credential_call_with_rp(
5555
resident_key: Some(ResidentKeyRequirement::Discouraged),
5656
user_verification: UserVerificationRequirement::Preferred,
5757
algorithms: vec![Ctap2CredentialType::default()],
58+
attestation: None,
5859
exclude: exclude_list,
5960
extensions: None,
6061
timeout: TIMEOUT,

libwebauthn-tests/tests/prf.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ async fn test_webauthn_prf_eval_at_create_degrades_when_unsupported() {
105105
resident_key: Some(ResidentKeyRequirement::Discouraged),
106106
user_verification: UserVerificationRequirement::Discouraged,
107107
algorithms: vec![Ctap2CredentialType::default()],
108+
attestation: None,
108109
exclude: None,
109110
extensions: Some(extensions),
110111
timeout: TIMEOUT,
@@ -179,6 +180,7 @@ async fn run_test_battery(channel: &mut HidChannel<'_>, using_pin: bool) {
179180
resident_key: Some(ResidentKeyRequirement::Discouraged),
180181
user_verification: UserVerificationRequirement::Preferred,
181182
algorithms: vec![Ctap2CredentialType::default()],
183+
attestation: None,
182184
exclude: None,
183185
extensions: Some(extensions),
184186
timeout: TIMEOUT,
@@ -672,6 +674,7 @@ async fn test_webauthn_prf_variable_length_input() {
672674
resident_key: Some(ResidentKeyRequirement::Discouraged),
673675
user_verification: UserVerificationRequirement::Preferred,
674676
algorithms: vec![Ctap2CredentialType::default()],
677+
attestation: None,
675678
exclude: None,
676679
extensions: Some(MakeCredentialsRequestExtensions {
677680
prf: Some(MakeCredentialPrfInput { eval: None }),
@@ -797,6 +800,7 @@ fn basic_make_credential_request(
797800
resident_key: Some(ResidentKeyRequirement::Discouraged),
798801
user_verification,
799802
algorithms: vec![Ctap2CredentialType::default()],
803+
attestation: None,
800804
exclude: None,
801805
extensions,
802806
timeout: TIMEOUT,

libwebauthn-tests/tests/signature_roundtrip.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ async fn test_ctap2_signature_roundtrip() {
7575
resident_key: Some(ResidentKeyRequirement::Discouraged),
7676
user_verification: UserVerificationRequirement::Preferred,
7777
algorithms: vec![Ctap2CredentialType::default()],
78+
attestation: None,
7879
exclude: None,
7980
extensions: Some(MakeCredentialsRequestExtensions {
8081
hmac_create_secret: Some(true),

libwebauthn/examples/features/webauthn_extensions_hid.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
6565
algorithms: vec![Ctap2CredentialType::default()],
6666
exclude: None,
6767
extensions: Some(extensions.clone()),
68+
attestation: None,
6869
timeout: TIMEOUT,
6970
};
7071

libwebauthn/examples/features/webauthn_preflight_hid.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ async fn make_credential_call(
120120
algorithms: vec![Ctap2CredentialType::default()],
121121
exclude: exclude_list,
122122
extensions: None,
123+
attestation: None,
123124
timeout: TIMEOUT,
124125
};
125126

libwebauthn/examples/features/webauthn_prf_cable.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ async fn create() -> Result<(), Box<dyn Error>> {
118118
algorithms: vec![Ctap2CredentialType::default()],
119119
exclude: None,
120120
extensions: Some(extensions),
121+
attestation: None,
121122
timeout: TIMEOUT,
122123
};
123124

libwebauthn/examples/features/webauthn_prf_hid.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
5858
algorithms: vec![Ctap2CredentialType::default()],
5959
exclude: None,
6060
extensions: Some(extensions.clone()),
61+
attestation: None,
6162
timeout: TIMEOUT,
6263
};
6364

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 158 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,35 @@ fn registration_transports(transport: Option<Transport>) -> Vec<String> {
8080
tokens
8181
}
8282

83+
fn scrub_aaguid(authenticator_data: &mut [u8]) -> Result<(), ResponseSerializationError> {
84+
const AAGUID_OFFSET: usize = 37;
85+
const AAGUID_LEN: usize = 16;
86+
authenticator_data
87+
.get_mut(AAGUID_OFFSET..AAGUID_OFFSET + AAGUID_LEN)
88+
.ok_or_else(|| {
89+
ResponseSerializationError::AuthenticatorDataError(
90+
"authenticator data too short to scrub AAGUID".into(),
91+
)
92+
})?
93+
.fill(0);
94+
Ok(())
95+
}
96+
97+
fn build_attestation_object(
98+
format: &str,
99+
attestation_statement: &Ctap2AttestationStatement,
100+
authenticator_data_bytes: &[u8],
101+
) -> Result<Vec<u8>, ResponseSerializationError> {
102+
let attestation_object = AttestationObject {
103+
format,
104+
auth_data: authenticator_data_bytes,
105+
attestation_statement,
106+
};
107+
108+
cbor::to_vec(&attestation_object)
109+
.map_err(|e| ResponseSerializationError::AttestationObjectError(e.to_string()))
110+
}
111+
83112
impl WebAuthnIDLResponse for MakeCredentialResponse {
84113
type IdlModel = RegistrationResponseJSON;
85114
type Context = MakeCredentialRequest;
@@ -102,11 +131,16 @@ impl WebAuthnIDLResponse for MakeCredentialResponse {
102131
let id = base64_url::encode(&attested.credential_id);
103132
let raw_id = Base64UrlString::from(attested.credential_id.clone());
104133

105-
let authenticator_data_bytes = self
134+
let mut authenticator_data_bytes = self
106135
.authenticator_data
107136
.to_response_bytes()
108137
.map_err(|e| ResponseSerializationError::AuthenticatorDataError(e.to_string()))?;
109138

139+
let scrub_attestation = request.attestation.as_deref() == Some("none");
140+
if scrub_attestation {
141+
scrub_aaguid(&mut authenticator_data_bytes)?;
142+
}
143+
110144
let public_key_algorithm = i64::from(
111145
cose::read_alg(&attested.credential_public_key)
112146
.map_err(|e| ResponseSerializationError::PublicKeyError(e.to_string()))?,
@@ -120,7 +154,14 @@ impl WebAuthnIDLResponse for MakeCredentialResponse {
120154
.map(Base64UrlString::from);
121155

122156
// Build attestation object (CBOR map with authData, fmt, attStmt)
123-
let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?;
157+
let none_statement = Ctap2AttestationStatement::None(BTreeMap::new());
158+
let (format, attestation_statement) = if scrub_attestation {
159+
("none", &none_statement)
160+
} else {
161+
(self.format.as_str(), &self.attestation_statement)
162+
};
163+
let attestation_object_bytes =
164+
build_attestation_object(format, attestation_statement, &authenticator_data_bytes)?;
124165

125166
// WebAuthn getTransports(): the authenticator's getInfo 0x09 transports
126167
// folded with the ceremony transport, unique tokens lexicographically sorted.
@@ -151,20 +192,6 @@ impl WebAuthnIDLResponse for MakeCredentialResponse {
151192
}
152193

153194
impl MakeCredentialResponse {
154-
fn build_attestation_object(
155-
&self,
156-
authenticator_data_bytes: &[u8],
157-
) -> Result<Vec<u8>, ResponseSerializationError> {
158-
let attestation_object = AttestationObject {
159-
format: &self.format,
160-
auth_data: authenticator_data_bytes,
161-
attestation_statement: &self.attestation_statement,
162-
};
163-
164-
cbor::to_vec(&attestation_object)
165-
.map_err(|e| ResponseSerializationError::AttestationObjectError(e.to_string()))
166-
}
167-
168195
fn build_client_extension_results(&self) -> AuthenticationExtensionsClientOutputsJSON {
169196
let mut results = AuthenticationExtensionsClientOutputsJSON::default();
170197
let unsigned_ext = &self.unsigned_extensions_output;
@@ -374,6 +401,8 @@ pub struct MakeCredentialRequest {
374401
pub exclude: Option<Vec<Ctap2PublicKeyCredentialDescriptor>>,
375402
/// extensions
376403
pub extensions: Option<MakeCredentialsRequestExtensions>,
404+
/// Attestation conveyance preference. `Some("none")` scrubs attestation.
405+
pub attestation: Option<String>,
377406
pub timeout: Duration,
378407
}
379408

@@ -468,6 +497,8 @@ impl FromIdlModel<PublicKeyCredentialCreationOptionsJSON> for MakeCredentialRequ
468497
)
469498
},
470499
extensions: inner.extensions,
500+
// WebAuthn IDL defaults attestation conveyance to "none".
501+
attestation: inner.attestation.or_else(|| Some("none".to_string())),
471502
timeout,
472503
})
473504
}
@@ -631,6 +662,7 @@ impl MakeCredentialRequest {
631662
algorithms: vec![Ctap2CredentialType::default()],
632663
exclude: None,
633664
extensions: None,
665+
attestation: None,
634666
resident_key: None,
635667
user_verification: UserVerificationRequirement::Discouraged,
636668
timeout: Duration::from_secs(10),
@@ -847,6 +879,7 @@ mod tests {
847879
algorithms: vec![Ctap2CredentialType::default()],
848880
exclude: None,
849881
extensions: None,
882+
attestation: Some("none".to_string()),
850883
timeout: Duration::from_secs(30),
851884
}
852885
}
@@ -1504,6 +1537,7 @@ mod tests {
15041537
algorithms: vec![Ctap2CredentialType::default()],
15051538
exclude: None,
15061539
extensions: None,
1540+
attestation: None,
15071541
timeout: Duration::from_secs(30),
15081542
}
15091543
}
@@ -1731,6 +1765,114 @@ mod tests {
17311765
}
17321766
}
17331767

1768+
fn create_attested_response(aaguid: [u8; 16]) -> MakeCredentialResponse {
1769+
use crate::fido::{AttestedCredentialData, AuthenticatorData, AuthenticatorDataFlags};
1770+
use crate::proto::ctap2::FidoU2fAttestationStmt;
1771+
use cosey::Bytes;
1772+
use serde_bytes::ByteBuf;
1773+
1774+
let cose_public_key = cosey::PublicKey::P256Key(cosey::P256PublicKey {
1775+
x: Bytes::from_slice(&[0u8; 32]).unwrap(),
1776+
y: Bytes::from_slice(&[0u8; 32]).unwrap(),
1777+
});
1778+
let credential_public_key = cbor::to_vec(&cose_public_key).unwrap();
1779+
1780+
let authenticator_data = AuthenticatorData {
1781+
rp_id_hash: [0u8; 32],
1782+
flags: AuthenticatorDataFlags::USER_PRESENT
1783+
| AuthenticatorDataFlags::ATTESTED_CREDENTIALS,
1784+
signature_count: 0,
1785+
attested_credential: Some(AttestedCredentialData {
1786+
aaguid,
1787+
credential_id: vec![0x01, 0x02, 0x03, 0x04],
1788+
credential_public_key,
1789+
}),
1790+
extensions: None,
1791+
raw: None,
1792+
};
1793+
1794+
MakeCredentialResponse {
1795+
format: "fido-u2f".to_string(),
1796+
authenticator_data,
1797+
attestation_statement: Ctap2AttestationStatement::FidoU2F(FidoU2fAttestationStmt {
1798+
signature: ByteBuf::from(vec![0xAA; 16]),
1799+
certificates: vec![ByteBuf::from(vec![0xBB; 8])],
1800+
}),
1801+
enterprise_attestation: None,
1802+
large_blob_key: None,
1803+
unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions::default(),
1804+
transport: None,
1805+
authenticator_transports: None,
1806+
}
1807+
}
1808+
1809+
#[test]
1810+
fn attestation_none_conveyance_scrubs_fmt_attstmt_and_aaguid() {
1811+
let response = create_attested_response([0x11u8; 16]);
1812+
let mut request = create_test_request();
1813+
request.attestation = Some("none".to_string());
1814+
1815+
let model = response.to_idl_model(&request).unwrap();
1816+
1817+
let auth_data = &model.response.authenticator_data.0;
1818+
assert_eq!(&auth_data[37..53], &[0u8; 16], "top-level authData AAGUID");
1819+
1820+
let attestation: cbor::Value =
1821+
cbor::from_slice(&model.response.attestation_object.0).unwrap();
1822+
let cbor::Value::Map(map) = attestation else {
1823+
panic!("attestation object should be a CBOR map");
1824+
};
1825+
let value_for = |key: &str| {
1826+
map.iter()
1827+
.find(|(k, _)| matches!(k, cbor::Value::Text(s) if s == key))
1828+
.map(|(_, v)| v)
1829+
};
1830+
assert!(
1831+
matches!(value_for("fmt"), Some(cbor::Value::Text(s)) if s == "none"),
1832+
"fmt must be scrubbed to none"
1833+
);
1834+
match value_for("attStmt") {
1835+
Some(cbor::Value::Map(stmt)) => assert!(stmt.is_empty(), "attStmt must be empty"),
1836+
other => panic!("attStmt must be an empty map, got {other:?}"),
1837+
}
1838+
match value_for("authData") {
1839+
Some(cbor::Value::Bytes(embedded)) => {
1840+
assert_eq!(&embedded[37..53], &[0u8; 16], "embedded authData AAGUID");
1841+
}
1842+
other => panic!("authData must be CBOR bytes, got {other:?}"),
1843+
}
1844+
}
1845+
1846+
#[test]
1847+
fn attestation_direct_preserves_attestation() {
1848+
let response = create_attested_response([0x11u8; 16]);
1849+
let mut request = create_test_request();
1850+
request.attestation = Some("direct".to_string());
1851+
1852+
let model = response.to_idl_model(&request).unwrap();
1853+
1854+
let auth_data = &model.response.authenticator_data.0;
1855+
assert_eq!(
1856+
&auth_data[37..53],
1857+
&[0x11u8; 16],
1858+
"AAGUID must be preserved"
1859+
);
1860+
1861+
let attestation: cbor::Value =
1862+
cbor::from_slice(&model.response.attestation_object.0).unwrap();
1863+
let cbor::Value::Map(map) = attestation else {
1864+
panic!("attestation object should be a CBOR map");
1865+
};
1866+
let fmt = map
1867+
.iter()
1868+
.find(|(k, _)| matches!(k, cbor::Value::Text(s) if s == "fmt"))
1869+
.map(|(_, v)| v);
1870+
assert!(
1871+
matches!(fmt, Some(cbor::Value::Text(s)) if s == "fido-u2f"),
1872+
"fmt must be preserved"
1873+
);
1874+
}
1875+
17341876
#[test]
17351877
fn test_response_with_extensions() {
17361878
let mut response = create_test_response();

0 commit comments

Comments
 (0)