Skip to content

Commit 3cee16a

Browse files
test(ctap2): verify signatures round-trip against the virtual authenticator
Register with hmac-secret, then check the assertion signature against the credential public key and the packed attestation signature against the embedded certificate, recomputing the client data hash from clientDataJSON.
1 parent d593d6b commit 3cee16a

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libwebauthn-tests/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ trussed-staging = { version = "0.3.0", features = ["chunked", "hkdf", "virt", "f
2525
aes-gcm = "0.10"
2626
base64-url = "3.0.0"
2727
flate2 = "1.0"
28+
p256 = { version = "0.13.2", features = ["ecdsa"] }
2829
serde_bytes = "0.11.5"
2930
serde_cbor_2 = "0.13"
3031
sha2 = "0.10"
3132
tempfile = "3.21"
3233
test-log = "0.2"
3334
tokio = { version = "1.45", features = ["full"] }
3435
tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
36+
x509-parser = "0.17.0"
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
use std::time::Duration;
2+
3+
use libwebauthn::fido::AuthenticatorDataFlags;
4+
use libwebauthn::ops::webauthn::{
5+
GetAssertionRequest, MakeCredentialRequest, MakeCredentialsRequestExtensions,
6+
ResidentKeyRequirement, UserVerificationRequirement, WebAuthnIDLResponse,
7+
};
8+
use libwebauthn::proto::ctap2::{
9+
cose, Ctap2, Ctap2AttestationStatement, Ctap2CredentialType,
10+
Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialRpEntity,
11+
Ctap2PublicKeyCredentialUserEntity,
12+
};
13+
use libwebauthn::transport::{ChannelSettings, Device};
14+
use libwebauthn::webauthn::WebAuthn;
15+
use libwebauthn_tests::virt::get_virtual_device;
16+
use p256::ecdsa::signature::Verifier;
17+
use p256::ecdsa::{Signature, VerifyingKey};
18+
use rand::{thread_rng, Rng};
19+
use sha2::{Digest, Sha256};
20+
use test_log::test;
21+
use x509_parser::prelude::{FromDer, SubjectPublicKeyInfo, X509Certificate};
22+
23+
const TIMEOUT: Duration = Duration::from_secs(10);
24+
25+
/// The attestation certificate the virtual authenticator is provisioned with.
26+
const ATTESTATION_CERT: &[u8] = include_bytes!("../src/virt/data/fido-cert.der");
27+
28+
/// Reads the P-256 verifying key out of an `id-ecPublicKey` SPKI.
29+
fn es256_key(spki: &SubjectPublicKeyInfo) -> VerifyingKey {
30+
VerifyingKey::from_sec1_bytes(spki.subject_public_key.data.as_ref())
31+
.expect("SPKI must carry a valid P-256 point")
32+
}
33+
34+
/// `authenticatorData || SHA-256(clientDataJSON)`, the bytes a WebAuthn
35+
/// signature is computed over (WebAuthn L3 6.3.3 and 8.2).
36+
fn signed_bytes(authenticator_data: &[u8], client_data_json: &[u8]) -> Vec<u8> {
37+
let mut message = authenticator_data.to_vec();
38+
message.extend_from_slice(Sha256::digest(client_data_json).as_slice());
39+
message
40+
}
41+
42+
fn verify_es256(key: &VerifyingKey, message: &[u8], der_signature: &[u8], label: &str) {
43+
let signature = Signature::from_der(der_signature)
44+
.unwrap_or_else(|e| panic!("{label}: signature is not DER ECDSA: {e}"));
45+
key.verify(message, &signature)
46+
.unwrap_or_else(|e| panic!("{label}: ES256 signature failed to verify: {e}"));
47+
}
48+
49+
/// End-to-end signature verification against the in-process virtual
50+
/// authenticator: the assertion signature over the credential key (WebAuthn L3
51+
/// 6.3.3) and the packed attestation signature over the embedded certificate
52+
/// (WebAuthn L3 8.2). The credential is registered with hmac-secret so the
53+
/// signed authenticatorData carries an extensions block, exercising the
54+
/// verbatim raw-bytes path from #249.
55+
#[test(tokio::test)]
56+
async fn test_ctap2_signature_roundtrip() {
57+
let mut device = get_virtual_device();
58+
let mut channel = device.channel(ChannelSettings::default()).await.unwrap();
59+
60+
let info = channel.ctap2_get_info().await.expect("GetInfo");
61+
assert!(
62+
info.extensions.iter().flatten().any(|e| e == "hmac-secret"),
63+
"virtual authenticator must advertise hmac-secret"
64+
);
65+
66+
let user_id: [u8; 32] = thread_rng().gen();
67+
let challenge: [u8; 32] = thread_rng().gen();
68+
69+
let make_request = MakeCredentialRequest {
70+
challenge: Vec::from(challenge),
71+
origin: "example.org".to_owned(),
72+
top_origin: None,
73+
relying_party: Ctap2PublicKeyCredentialRpEntity::new("example.org", "example.org"),
74+
user: Ctap2PublicKeyCredentialUserEntity::new(&user_id, "alice", "Alice"),
75+
resident_key: Some(ResidentKeyRequirement::Discouraged),
76+
user_verification: UserVerificationRequirement::Preferred,
77+
algorithms: vec![Ctap2CredentialType::default()],
78+
exclude: None,
79+
extensions: Some(MakeCredentialsRequestExtensions {
80+
hmac_create_secret: Some(true),
81+
..Default::default()
82+
}),
83+
timeout: TIMEOUT,
84+
};
85+
86+
let registration = channel
87+
.webauthn_make_credential(&make_request)
88+
.await
89+
.expect("Failed to register credential");
90+
91+
// The signed authenticatorData must carry an extensions block (#249).
92+
assert!(
93+
registration
94+
.authenticator_data
95+
.flags
96+
.contains(AuthenticatorDataFlags::EXTENSION_DATA),
97+
"attested authenticatorData must set the extension-data flag"
98+
);
99+
assert_eq!(
100+
registration
101+
.authenticator_data
102+
.extensions
103+
.as_ref()
104+
.and_then(|e| e.hmac_secret),
105+
Some(true),
106+
"credential must be created with hmac-secret"
107+
);
108+
assert!(
109+
registration.authenticator_data.raw.is_some(),
110+
"device authenticatorData must be preserved verbatim"
111+
);
112+
113+
// Attestation path (WebAuthn L3 8.2): the authData embedded in the
114+
// client-emitted attestationObject must be byte-identical to the
115+
// authenticatorData surfaced alongside it, and the packed attStmt
116+
// signature must verify against the embedded attestation certificate.
117+
let idl = registration
118+
.to_idl_model(&make_request)
119+
.expect("attestation IDL model");
120+
let attestation_object = idl.response.attestation_object.as_slice();
121+
let attested_authenticator_data = idl.response.authenticator_data.as_slice();
122+
let registration_client_data = idl.response.client_data_json.as_slice();
123+
124+
assert!(
125+
attestation_object
126+
.windows(attested_authenticator_data.len())
127+
.any(|w| w == attested_authenticator_data),
128+
"attestationObject must embed authData verbatim"
129+
);
130+
131+
let (attestation_signature, x5c) = match &registration.attestation_statement {
132+
Ctap2AttestationStatement::PackedOrAndroid(stmt) => (
133+
stmt.signature.as_ref(),
134+
stmt.certificates
135+
.first()
136+
.expect("packed attStmt must contain an x5c certificate")
137+
.as_ref(),
138+
),
139+
other => panic!("expected a packed attestation statement, got {other:?}"),
140+
};
141+
assert_eq!(
142+
x5c, ATTESTATION_CERT,
143+
"x5c must be the embedded attestation certificate"
144+
);
145+
146+
let (_, certificate) = X509Certificate::from_der(x5c).expect("attestation certificate");
147+
let attestation_key = es256_key(certificate.public_key());
148+
verify_es256(
149+
&attestation_key,
150+
&signed_bytes(attested_authenticator_data, registration_client_data),
151+
attestation_signature,
152+
"attestation",
153+
);
154+
155+
// Assertion path (WebAuthn L3 6.3.3): the assertion signature must verify
156+
// against the credential public key taken from the registration authData.
157+
let credential_cose = &registration
158+
.authenticator_data
159+
.attested_credential
160+
.as_ref()
161+
.expect("attested credential")
162+
.credential_public_key;
163+
let credential_spki = cose::to_spki(credential_cose)
164+
.expect("COSE key to SPKI")
165+
.expect("ES256 credential yields an SPKI");
166+
let (_, credential_spki) =
167+
SubjectPublicKeyInfo::from_der(&credential_spki).expect("credential SPKI");
168+
let credential_key = es256_key(&credential_spki);
169+
170+
let credential: Ctap2PublicKeyCredentialDescriptor =
171+
(&registration.authenticator_data).try_into().unwrap();
172+
let get_assertion = GetAssertionRequest {
173+
relying_party_id: "example.org".to_owned(),
174+
challenge: Vec::from(challenge),
175+
origin: "example.org".to_owned(),
176+
top_origin: None,
177+
allow: vec![credential],
178+
user_verification: UserVerificationRequirement::Discouraged,
179+
extensions: None,
180+
timeout: TIMEOUT,
181+
};
182+
183+
let assertion_response = channel
184+
.webauthn_get_assertion(&get_assertion)
185+
.await
186+
.expect("Failed to get assertion");
187+
let assertion = assertion_response
188+
.assertions
189+
.first()
190+
.expect("at least one assertion");
191+
192+
let assertion_authenticator_data = assertion
193+
.authenticator_data
194+
.to_response_bytes()
195+
.expect("assertion authenticatorData");
196+
verify_es256(
197+
&credential_key,
198+
&signed_bytes(
199+
&assertion_authenticator_data,
200+
get_assertion.client_data_json().as_bytes(),
201+
),
202+
&assertion.signature,
203+
"assertion",
204+
);
205+
}

0 commit comments

Comments
 (0)