Skip to content

Commit 983a601

Browse files
fix(preflight): treat only no-credentials as credential absence
Preflight mapped every getAssertion error to absence, so a transport timeout or any non-NoCredentials CTAP status silently dropped a credential from the filtered list. Only CTAP2_ERR_NO_CREDENTIALS proves a credential is absent, per the CTAP pre-flight procedure. Propagate all other errors to the caller so transient failures are not mistaken for absence.
1 parent c666b45 commit 983a601

3 files changed

Lines changed: 119 additions & 19 deletions

File tree

libwebauthn-tests/tests/preflight.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ async fn preflight_no_exclude_list() {
105105

106106
let state_recv = channel.get_ux_update_receiver();
107107
let hash: [u8; 32] = thread_rng().gen();
108-
let filtered_list = ctap2_preflight(&mut channel, &[], &hash, "example.org").await;
108+
let filtered_list = ctap2_preflight(&mut channel, &[], &hash, "example.org")
109+
.await
110+
.expect("preflight failed");
109111
assert!(filtered_list.is_empty());
110112

111113
let res = make_credential_call(&mut channel, &user_id, None).await;
@@ -133,7 +135,9 @@ async fn preflight_nonsense_exclude_list() {
133135
];
134136

135137
let hash: [u8; 32] = thread_rng().gen();
136-
let filtered_list = ctap2_preflight(&mut channel, &exclude_list, &hash, "example.org").await;
138+
let filtered_list = ctap2_preflight(&mut channel, &exclude_list, &hash, "example.org")
139+
.await
140+
.expect("preflight failed");
137141
assert!(filtered_list.is_empty());
138142

139143
let res = make_credential_call(&mut channel, &user_id, Some(exclude_list)).await;
@@ -168,7 +172,9 @@ async fn preflight_mixed_exclude_list() {
168172
create_credential(&[7, 6, 5, 4, 3, 2, 1, 9, 8]),
169173
];
170174

171-
let filtered_list = ctap2_preflight(&mut channel, &exclude_list, &hash, "example.org").await;
175+
let filtered_list = ctap2_preflight(&mut channel, &exclude_list, &hash, "example.org")
176+
.await
177+
.expect("preflight failed");
172178
assert_eq!(filtered_list.len(), 2);
173179
assert_eq!(filtered_list[0].id, first_credential.id);
174180
assert_eq!(filtered_list[1].id, second_credential.id);
@@ -203,7 +209,9 @@ async fn preflight_no_allow_list() {
203209
let res = make_credential_call(&mut channel, &user_id, None).await;
204210
let (_credential, hash) = res.expect("Failed to register first credential");
205211

206-
let filtered_list = ctap2_preflight(&mut channel, &[], &hash, "example.org").await;
212+
let filtered_list = ctap2_preflight(&mut channel, &[], &hash, "example.org")
213+
.await
214+
.expect("preflight failed");
207215
assert!(filtered_list.is_empty());
208216

209217
let allow_list = Vec::new();
@@ -236,7 +244,9 @@ async fn preflight_nonsense_allow_list() {
236244
create_credential(&[7, 6, 5, 4, 3, 2, 1, 9, 8]),
237245
];
238246

239-
let filtered_list = ctap2_preflight(&mut channel, &allow_list, &hash, "example.org").await;
247+
let filtered_list = ctap2_preflight(&mut channel, &allow_list, &hash, "example.org")
248+
.await
249+
.expect("preflight failed");
240250
assert!(filtered_list.is_empty());
241251

242252
let res = get_assertion_call(&mut channel, allow_list).await;
@@ -277,7 +287,8 @@ async fn preflight_with_appid_exclude_finds_legacy_credential() {
277287
"example.org",
278288
None,
279289
)
280-
.await;
290+
.await
291+
.expect("preflight failed");
281292
assert!(
282293
filtered_no_appid.is_empty(),
283294
"Without appid_exclude, the legacy credential should not be detected"
@@ -292,7 +303,8 @@ async fn preflight_with_appid_exclude_finds_legacy_credential() {
292303
"example.org",
293304
Some("legacy.example.org"),
294305
)
295-
.await;
306+
.await
307+
.expect("preflight failed");
296308
assert_eq!(
297309
filtered_with_appid.len(),
298310
1,
@@ -325,7 +337,9 @@ async fn preflight_mixed_allow_list() {
325337
second_credential.clone(),
326338
];
327339

328-
let filtered_list = ctap2_preflight(&mut channel, &allow_list, &hash, "example.org").await;
340+
let filtered_list = ctap2_preflight(&mut channel, &allow_list, &hash, "example.org")
341+
.await
342+
.expect("preflight failed");
329343
assert_eq!(filtered_list.len(), 2);
330344
assert_eq!(filtered_list[0].id, first_credential.id);
331345
assert_eq!(filtered_list[1].id, second_credential.id);

libwebauthn/src/proto/ctap2/preflight.rs

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use super::{Ctap2GetAssertionRequest, Ctap2PublicKeyCredentialDescriptor};
66
use crate::{
77
proto::ctap2::{model::Ctap2GetAssertionOptions, Ctap2},
88
transport::Channel,
9+
webauthn::error::{CtapError, Error},
910
};
1011

1112
/// https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#pre-flight
@@ -23,7 +24,7 @@ pub async fn ctap2_preflight<C: Channel>(
2324
credentials: &[Ctap2PublicKeyCredentialDescriptor],
2425
client_data_hash: &[u8],
2526
rp: &str,
26-
) -> Vec<Ctap2PublicKeyCredentialDescriptor> {
27+
) -> Result<Vec<Ctap2PublicKeyCredentialDescriptor>, Error> {
2728
ctap2_preflight_with_appid(channel, credentials, client_data_hash, rp, None).await
2829
}
2930

@@ -38,12 +39,12 @@ pub async fn ctap2_preflight_with_appid<C: Channel>(
3839
client_data_hash: &[u8],
3940
rp: &str,
4041
appid_exclude: Option<&str>,
41-
) -> Vec<Ctap2PublicKeyCredentialDescriptor> {
42+
) -> Result<Vec<Ctap2PublicKeyCredentialDescriptor>, Error> {
4243
info!("Credential list BEFORE preflight: {credentials:?}");
4344
let mut filtered_list = Vec::new();
4445
for credential in credentials {
4546
// Test against the canonical rpId first.
46-
if let Some(matched) = preflight_one(channel, credential, client_data_hash, rp).await {
47+
if let Some(matched) = preflight_one(channel, credential, client_data_hash, rp).await? {
4748
debug!("Pre-flight: Found already known credential under rpId {credential:?}");
4849
filtered_list.push(matched);
4950
continue;
@@ -54,7 +55,8 @@ pub async fn ctap2_preflight_with_appid<C: Channel>(
5455
// the AppID URL as the rpId here; the authenticator hashes it the
5556
// same way the U2F device hashed the original AppID.
5657
if let Some(appid) = appid_exclude {
57-
if let Some(matched) = preflight_one(channel, credential, client_data_hash, appid).await
58+
if let Some(matched) =
59+
preflight_one(channel, credential, client_data_hash, appid).await?
5860
{
5961
debug!(
6062
"Pre-flight: Found already known credential under appidExclude {credential:?}"
@@ -66,15 +68,15 @@ pub async fn ctap2_preflight_with_appid<C: Channel>(
6668
debug!("Pre-flight: Filtering out {credential:?}");
6769
}
6870
info!("Credential list AFTER preflight: {filtered_list:?}");
69-
filtered_list
71+
Ok(filtered_list)
7072
}
7173

7274
async fn preflight_one<C: Channel>(
7375
channel: &mut C,
7476
credential: &Ctap2PublicKeyCredentialDescriptor,
7577
client_data_hash: &[u8],
7678
rp: &str,
77-
) -> Option<Ctap2PublicKeyCredentialDescriptor> {
79+
) -> Result<Option<Ctap2PublicKeyCredentialDescriptor>, Error> {
7880
let preflight_request = Ctap2GetAssertionRequest {
7981
relying_party_id: rp.to_string(),
8082
client_data_hash: ByteBuf::from(client_data_hash),
@@ -105,11 +107,95 @@ async fn preflight_one<C: Channel>(
105107
// 3. Neither, which is allowed, if the allow_list was of length 1, then
106108
// we have to copy it ourselfs from the input
107109
.unwrap_or(credential.clone());
108-
Some(id)
110+
Ok(Some(id))
109111
}
112+
// Only CTAP2_ERR_NO_CREDENTIALS proves the credential is absent.
113+
Err(Error::Ctap(CtapError::NoCredentials)) => {
114+
debug!("Pre-flight: Not found under {rp:?}");
115+
Ok(None)
116+
}
117+
// Any other error is transient or unexpected, not absence: propagate it.
110118
Err(e) => {
111-
debug!("Pre-flight: Not found under {rp:?}: {e:?}");
112-
None
119+
debug!("Pre-flight: Error testing under {rp:?}: {e:?}");
120+
Err(e)
121+
}
122+
}
123+
}
124+
125+
#[cfg(test)]
126+
mod tests {
127+
use serde_bytes::ByteBuf;
128+
129+
use super::ctap2_preflight;
130+
use crate::proto::ctap2::cbor::{CborRequest, CborResponse};
131+
use crate::proto::ctap2::model::Ctap2GetAssertionOptions;
132+
use crate::proto::ctap2::{
133+
Ctap2GetAssertionRequest, Ctap2PublicKeyCredentialDescriptor, Ctap2PublicKeyCredentialType,
134+
};
135+
use crate::transport::mock::channel::MockChannel;
136+
use crate::webauthn::error::{CtapError, Error};
137+
138+
fn credential(id: &[u8]) -> Ctap2PublicKeyCredentialDescriptor {
139+
Ctap2PublicKeyCredentialDescriptor {
140+
r#type: Ctap2PublicKeyCredentialType::PublicKey,
141+
id: ByteBuf::from(id),
142+
transports: None,
113143
}
114144
}
145+
146+
fn expected_preflight_request(
147+
cred: &Ctap2PublicKeyCredentialDescriptor,
148+
hash: &[u8],
149+
rp: &str,
150+
) -> CborRequest {
151+
let request = Ctap2GetAssertionRequest {
152+
relying_party_id: rp.to_string(),
153+
client_data_hash: ByteBuf::from(hash),
154+
allow: vec![cred.clone()],
155+
extensions: None,
156+
options: Some(Ctap2GetAssertionOptions {
157+
require_user_presence: false,
158+
require_user_verification: false,
159+
}),
160+
pin_auth_param: None,
161+
pin_auth_proto: None,
162+
};
163+
(&request).try_into().expect("encode preflight request")
164+
}
165+
166+
#[tokio::test]
167+
async fn preflight_propagates_non_no_credentials_error() {
168+
let cred = credential(&[1, 2, 3, 4]);
169+
let hash = [0u8; 32];
170+
let mut channel = MockChannel::new();
171+
channel.push_command_pair(
172+
expected_preflight_request(&cred, &hash, "example.org"),
173+
CborResponse {
174+
status_code: CtapError::OperationDenied,
175+
data: None,
176+
},
177+
);
178+
179+
let result = ctap2_preflight(&mut channel, &[cred], &hash, "example.org").await;
180+
assert_eq!(result.err(), Some(Error::Ctap(CtapError::OperationDenied)));
181+
}
182+
183+
#[tokio::test]
184+
async fn preflight_treats_no_credentials_as_absence() {
185+
let cred = credential(&[1, 2, 3, 4]);
186+
let hash = [0u8; 32];
187+
let mut channel = MockChannel::new();
188+
channel.push_command_pair(
189+
expected_preflight_request(&cred, &hash, "example.org"),
190+
CborResponse {
191+
status_code: CtapError::NoCredentials,
192+
data: None,
193+
},
194+
);
195+
196+
let filtered = ctap2_preflight(&mut channel, &[cred], &hash, "example.org")
197+
.await
198+
.expect("preflight should succeed on NoCredentials");
199+
assert!(filtered.is_empty());
200+
}
115201
}

libwebauthn/src/webauthn.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ async fn make_credential_fido2<C: Channel>(
251251
&op.relying_party.id,
252252
appid_exclude,
253253
)
254-
.await;
254+
.await?;
255255
ctap2_request.exclude = Some(filtered_exclude_list);
256256
}
257257
}
@@ -340,7 +340,7 @@ async fn get_assertion_fido2<C: Channel>(
340340
&op.client_data_hash(),
341341
&op.relying_party_id,
342342
)
343-
.await;
343+
.await?;
344344
if filtered_allow_list.is_empty() && !op.allow.is_empty() {
345345
// We filtered out everything in preflight, meaning none of the allowed
346346
// credentials are present on this device. So we error out here

0 commit comments

Comments
 (0)