From d8345340ab8229dbd73ae239b2603ac871ec6735 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Sun, 7 Jun 2026 21:46:21 +0100 Subject: [PATCH 1/5] feat(transport): advertise nfc and hybrid and populate registration transports Add NFC and hybrid/caBLE to the public Transport enum and report compiled-in transports from available_transports. NFC is gated behind the nfc-backend features and hybrid is always present. Thread the active transport from the channel into registration response serialization so the response transports member carries the AuthenticatorTransport token. The list is deduplicated, sorted, and stays empty when the transport is unknown. Both the FIDO2 and U2F-downgrade paths are covered. --- libwebauthn/examples/ceremony/webauthn_ble.rs | 12 +++- .../examples/ceremony/webauthn_cable.rs | 2 +- .../examples/ceremony/webauthn_cable_wss.rs | 4 +- libwebauthn/examples/ceremony/webauthn_hid.rs | 12 +++- libwebauthn/examples/ceremony/webauthn_nfc.rs | 8 ++- .../examples/features/webauthn_prf_cable.rs | 4 +- .../features/webauthn_related_origins_hid.rs | 2 +- libwebauthn/src/lib.rs | 36 +++++++++- libwebauthn/src/ops/webauthn/get_assertion.rs | 13 ++-- libwebauthn/src/ops/webauthn/idl/response.rs | 11 +++- .../src/ops/webauthn/make_credential.rs | 66 ++++++++++++++++--- libwebauthn/src/proto/ctap2/model.rs | 11 ++++ .../src/proto/ctap2/model/get_assertion.rs | 4 +- libwebauthn/src/transport/ble/channel.rs | 4 ++ libwebauthn/src/transport/cable/channel.rs | 4 ++ libwebauthn/src/transport/channel.rs | 6 ++ libwebauthn/src/transport/hid/channel.rs | 4 ++ libwebauthn/src/transport/nfc/channel.rs | 4 ++ 18 files changed, 174 insertions(+), 33 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index 981214ef..e54025da 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -75,7 +75,11 @@ pub async fn main() -> Result<(), Box> { .unwrap(); println!("WebAuthn MakeCredential response: {:?}", response); - match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) { + match response.to_json_string( + &make_credentials_request, + channel.transport(), + JsonFormat::Prettified, + ) { Ok(response_json) => { println!( "WebAuthn MakeCredential response (JSON):\n{}", @@ -113,7 +117,11 @@ pub async fn main() -> Result<(), Box> { println!("WebAuthn GetAssertion response: {:?}", response); for assertion in &response.assertions { - match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) { + match assertion.to_json_string( + &get_assertion, + channel.transport(), + JsonFormat::Prettified, + ) { Ok(assertion_json) => { println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json); } diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index 975c3a67..a4d5985b 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -92,7 +92,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, JsonFormat::Prettified) + .to_json_string(&request, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs index e9555583..b3c82052 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs @@ -110,7 +110,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, JsonFormat::Prettified) + .to_json_string(&request, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); } @@ -182,7 +182,7 @@ async fn run_get_assertion( let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap(); for assertion in &response.assertions { let assertion_json = assertion - .to_json_string(&request, JsonFormat::Prettified) + .to_json_string(&request, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize GetAssertion response"); println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}"); } diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index 6e7ec823..5939b9c3 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -79,7 +79,11 @@ pub async fn main() -> Result<(), Box> { .unwrap(); println!("WebAuthn MakeCredential response: {:?}", response); - match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) { + match response.to_json_string( + &make_credentials_request, + channel.transport(), + JsonFormat::Prettified, + ) { Ok(response_json) => { println!( "WebAuthn MakeCredential response (JSON):\n{}", @@ -117,7 +121,11 @@ pub async fn main() -> Result<(), Box> { println!("WebAuthn GetAssertion response: {:?}", response); for assertion in &response.assertions { - match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) { + match assertion.to_json_string( + &get_assertion, + channel.transport(), + JsonFormat::Prettified, + ) { Ok(assertion_json) => { println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json); } diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index f3d27654..0d88e443 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -77,7 +77,11 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&make_credentials_request)).unwrap(); let response_json = response - .to_json_string(&make_credentials_request, JsonFormat::Prettified) + .to_json_string( + &make_credentials_request, + channel.transport(), + JsonFormat::Prettified, + ) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); @@ -100,7 +104,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap(); for assertion in &response.assertions { let assertion_json = assertion - .to_json_string(&get_assertion, JsonFormat::Prettified) + .to_json_string(&get_assertion, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize GetAssertion response"); println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}"); } diff --git a/libwebauthn/examples/features/webauthn_prf_cable.rs b/libwebauthn/examples/features/webauthn_prf_cable.rs index c375ff41..98c7d162 100644 --- a/libwebauthn/examples/features/webauthn_prf_cable.rs +++ b/libwebauthn/examples/features/webauthn_prf_cable.rs @@ -124,7 +124,7 @@ async fn create() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, JsonFormat::Prettified) + .to_json_string(&request, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); @@ -191,7 +191,7 @@ async fn get(credential_id: Option<&str>) -> Result<(), Box> { for (num, assertion) in response.assertions.iter().enumerate() { let assertion_json = assertion - .to_json_string(&request, JsonFormat::Prettified) + .to_json_string(&request, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize GetAssertion response"); println!("Assertion {num} (JSON):\n{assertion_json}"); } diff --git a/libwebauthn/examples/features/webauthn_related_origins_hid.rs b/libwebauthn/examples/features/webauthn_related_origins_hid.rs index d83620f3..adf1b38b 100644 --- a/libwebauthn/examples/features/webauthn_related_origins_hid.rs +++ b/libwebauthn/examples/features/webauthn_related_origins_hid.rs @@ -76,7 +76,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, JsonFormat::Prettified) + .to_json_string(&request, channel.transport(), JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); } diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs index 6b64e279..0f4c5a4d 100644 --- a/libwebauthn/src/lib.rs +++ b/libwebauthn/src/lib.rs @@ -120,10 +120,12 @@ macro_rules! unwrap_field { use pin::{PinNotSetReason, PinRequestReason}; pub(crate) use unwrap_field; -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Transport { Usb, Ble, + Nfc, + Hybrid, } #[derive(Debug, Clone)] @@ -213,6 +215,36 @@ impl PartialEq for PinNotSetUpdate { } } +/// Transports compiled into this build. Hybrid/caBLE is always included. Using it +/// at runtime still needs a BLE adapter (see `transport::cable::is_available`). +/// NFC appears only when an `nfc-backend-*` feature is enabled. pub fn available_transports() -> Vec { - vec![Transport::Usb, Transport::Ble] + [ + Transport::Usb, + Transport::Ble, + Transport::Hybrid, + #[cfg(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc"))] + Transport::Nfc, + ] + .into_iter() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn available_transports_reports_compiled_in() { + let transports = available_transports(); + assert!(transports.contains(&Transport::Usb)); + assert!(transports.contains(&Transport::Ble)); + assert!(transports.contains(&Transport::Hybrid)); + + let nfc_compiled = cfg!(any( + feature = "nfc-backend-pcsc", + feature = "nfc-backend-libnfc" + )); + assert_eq!(transports.contains(&Transport::Nfc), nfc_compiled); + } } diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 2498044b..637dacef 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -489,6 +489,7 @@ impl WebAuthnIDLResponse for Assertion { fn to_idl_model( &self, request: &Self::Context, + _transport: Option, ) -> Result { // Get credential ID - either from credential_id field or from authenticator_data let credential_id_bytes = self @@ -1306,7 +1307,7 @@ mod tests { let assertion = create_test_assertion(); let request = create_test_request(); - let json = assertion.to_json_string(&request, JsonFormat::default()); + let json = assertion.to_json_string(&request, None, JsonFormat::default()); assert!(json.is_ok()); let json_str = json.unwrap(); @@ -1332,7 +1333,7 @@ mod tests { fn test_assertion_to_idl_model() { let assertion = create_test_assertion(); let request = create_test_request(); - let model = assertion.to_idl_model(&request).unwrap(); + let model = assertion.to_idl_model(&request, None).unwrap(); // Verify the credential ID assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]); @@ -1354,7 +1355,7 @@ mod tests { )); let request = create_test_request(); - let model = assertion.to_idl_model(&request).unwrap(); + let model = assertion.to_idl_model(&request, None).unwrap(); // Verify user handle is present assert!(model.response.user_handle.is_some()); @@ -1381,7 +1382,7 @@ mod tests { }); let request = create_test_request(); - let model = assertion.to_idl_model(&request).unwrap(); + let model = assertion.to_idl_model(&request, None).unwrap(); // Verify extension outputs - PRF should be set with correct values let prf = model.client_extension_results.prf.as_ref().unwrap(); @@ -1403,7 +1404,7 @@ mod tests { }); let request = create_test_request(); - let model = assertion.to_idl_model(&request).unwrap(); + let model = assertion.to_idl_model(&request, None).unwrap(); assert_eq!(model.client_extension_results.appid, Some(true)); // The output should also round-trip through the JSON wire format. @@ -1427,7 +1428,7 @@ mod tests { }); let request = create_test_request(); - let model = assertion.to_idl_model(&request).unwrap(); + let model = assertion.to_idl_model(&request, None).unwrap(); assert_eq!(model.client_extension_results.appid, None); let json = serde_json::to_value(&model.client_extension_results).unwrap(); diff --git a/libwebauthn/src/ops/webauthn/idl/response.rs b/libwebauthn/src/ops/webauthn/idl/response.rs index c975aaa9..9972b4bf 100644 --- a/libwebauthn/src/ops/webauthn/idl/response.rs +++ b/libwebauthn/src/ops/webauthn/idl/response.rs @@ -55,18 +55,22 @@ pub trait WebAuthnIDLResponse: Sized { /// Context required for serialization (e.g., client data JSON). type Context; - /// Converts this response to a JSON-serializable IDL model. + /// Converts this response to a JSON-serializable IDL model. `transport` is the + /// transport the ceremony ran over, used to populate the registration + /// `transports` member. Pass `None` when it is unknown. fn to_idl_model( &self, ctx: &Self::Context, + transport: Option, ) -> Result; /// Serializes this response to a `serde_json::Value`. fn to_json_value( &self, ctx: &Self::Context, + transport: Option, ) -> Result { - let model = self.to_idl_model(ctx)?; + let model = self.to_idl_model(ctx, transport)?; Ok(serde_json::to_value(&model)?) } @@ -74,9 +78,10 @@ pub trait WebAuthnIDLResponse: Sized { fn to_json_string( &self, ctx: &Self::Context, + transport: Option, format: JsonFormat, ) -> Result { - let value = self.to_json_value(ctx)?; + let value = self.to_json_value(ctx, transport)?; match format { JsonFormat::Minified => Ok(serde_json::to_string(&value)?), JsonFormat::Prettified => Ok(serde_json::to_string_pretty(&value)?), diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index a75db158..367c6190 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -29,7 +29,7 @@ use crate::{ cbor, cbor::Value, cose, parse_unsigned_prf, Ctap2AttestationStatement, Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2GetInfoResponse, Ctap2MakeCredentialsResponseExtensions, Ctap2PublicKeyCredentialDescriptor, - Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, + Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, Ctap2Transport, UnsignedPrfOutput, }, }, @@ -60,6 +60,21 @@ struct AttestationObject<'a> { attestation_statement: &'a Ctap2AttestationStatement, } +/// Maps the active transport to AuthenticatorTransport tokens for the registration +/// `transports` member. The list is deduplicated and lexicographically sorted per +/// WebAuthn L3 §5.2.1.1, and is empty when the transport is unknown. +fn registration_transports(transport: Option) -> Vec { + let mut tokens: Vec = transport + .into_iter() + .map(Ctap2Transport::from) + .filter_map(|t| serde_json::to_value(t).ok()) + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(); + tokens.sort(); + tokens.dedup(); + tokens +} + impl WebAuthnIDLResponse for MakeCredentialResponse { type IdlModel = RegistrationResponseJSON; type Context = MakeCredentialRequest; @@ -67,6 +82,7 @@ impl WebAuthnIDLResponse for MakeCredentialResponse { fn to_idl_model( &self, request: &Self::Context, + transport: Option, ) -> Result { // The AT flag MUST be set on makeCredential responses per CTAP 2.2 §6.1. let attested = self @@ -102,8 +118,7 @@ impl WebAuthnIDLResponse for MakeCredentialResponse { // Build attestation object (CBOR map with authData, fmt, attStmt) let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?; - // Get transports (we don't have direct access, so return empty for now) - let transports = Vec::new(); + let transports = registration_transports(transport); // Build client extension results let client_extension_results = self.build_client_extension_results(); @@ -1434,7 +1449,7 @@ mod tests { let response = create_test_response(); let request = create_test_request(); - let json = response.to_json_string(&request, JsonFormat::default()); + let json = response.to_json_string(&request, None, JsonFormat::default()); assert!(json.is_ok()); let json_str = json.unwrap(); @@ -1489,7 +1504,7 @@ mod tests { fn test_response_to_idl_model() { let response = create_test_response(); let request = create_test_request(); - let model = response.to_idl_model(&request).unwrap(); + let model = response.to_idl_model(&request, None).unwrap(); // Verify the credential ID assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]); @@ -1503,6 +1518,41 @@ mod tests { assert!(model.response.transports.is_empty()); } + #[test] + fn test_response_to_idl_model_populates_transports() { + // WebAuthn L3 §5.2.1.1: the registration `transports` member reports the + // transport the credential was created over, as AuthenticatorTransport tokens. + // Both the FIDO2 and U2F-downgrade paths converge on this serialization. + let response = create_test_response(); + let request = create_test_request(); + + for (transport, token) in [ + (crate::Transport::Usb, "usb"), + (crate::Transport::Ble, "ble"), + (crate::Transport::Nfc, "nfc"), + (crate::Transport::Hybrid, "hybrid"), + ] { + let model = response.to_idl_model(&request, Some(transport)).unwrap(); + assert_eq!(model.response.transports, vec![token.to_string()]); + } + + // The token reaches the JSON wire format too. + let json = response + .to_json_string( + &request, + Some(crate::Transport::Nfc), + crate::ops::webauthn::idl::response::JsonFormat::default(), + ) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let transports = parsed["response"]["transports"].as_array().unwrap(); + assert_eq!(transports, &vec![serde_json::Value::from("nfc")]); + + // An unknown transport leaves the list empty. + let model = response.to_idl_model(&request, None).unwrap(); + assert!(model.response.transports.is_empty()); + } + #[test] fn test_response_emits_spki_for_es256() { // The test fixture builds an ES256 P-256 credential, so getPublicKey() @@ -1512,7 +1562,7 @@ mod tests { // by the secp256r1 OID and the uncompressed point. let response = create_test_response(); let request = create_test_request(); - let model = response.to_idl_model(&request).unwrap(); + let model = response.to_idl_model(&request, None).unwrap(); let public_key_bytes = model .response @@ -1536,7 +1586,7 @@ mod tests { fn test_response_attestation_object_format() { let response = create_test_response(); let request = create_test_request(); - let model = response.to_idl_model(&request).unwrap(); + let model = response.to_idl_model(&request, None).unwrap(); // Decode the attestation object let attestation_bytes = model.response.attestation_object.0; @@ -1581,7 +1631,7 @@ mod tests { }; let request = create_test_request(); - let model = response.to_idl_model(&request).unwrap(); + let model = response.to_idl_model(&request, None).unwrap(); // Verify cred_props extension let cred_props = model.client_extension_results.cred_props.as_ref().unwrap(); diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index d7b8e4b0..3d502413 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -167,6 +167,17 @@ impl From<&Ctap1Transport> for Ctap2Transport { } } +impl From for Ctap2Transport { + fn from(transport: crate::Transport) -> Ctap2Transport { + match transport { + crate::Transport::Usb => Ctap2Transport::Usb, + crate::Transport::Ble => Ctap2Transport::Ble, + crate::Transport::Nfc => Ctap2Transport::Nfc, + crate::Transport::Hybrid => Ctap2Transport::Hybrid, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Ctap2PublicKeyCredentialDescriptor { pub id: ByteBuf, diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index 08c60fa6..b4f769e1 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -1337,7 +1337,7 @@ mod tests { ); let assertion = parsed.into_assertion_output(&request, None); let json_str = assertion - .to_json_string(&request, JsonFormat::default()) + .to_json_string(&request, None, JsonFormat::default()) .unwrap(); let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -1442,7 +1442,7 @@ mod tests { let request = make_request(vec![make_credential(b"cred-1")]); let assertion = parsed.into_assertion_output(&request, None); let json_str = assertion - .to_json_string(&request, JsonFormat::default()) + .to_json_string(&request, None, JsonFormat::default()) .unwrap(); let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); diff --git a/libwebauthn/src/transport/ble/channel.rs b/libwebauthn/src/transport/ble/channel.rs index 5ccc3dce..77029ca6 100644 --- a/libwebauthn/src/transport/ble/channel.rs +++ b/libwebauthn/src/transport/ble/channel.rs @@ -81,6 +81,10 @@ impl Display for BleChannel<'_> { impl<'a> Channel for BleChannel<'a> { type UxUpdate = UvUpdate; + fn transport(&self) -> Option { + Some(crate::Transport::Ble) + } + async fn supported_protocols(&self) -> Result { Ok(self.revision.into()) } diff --git a/libwebauthn/src/transport/cable/channel.rs b/libwebauthn/src/transport/cable/channel.rs index 58176e92..97127c8b 100644 --- a/libwebauthn/src/transport/cable/channel.rs +++ b/libwebauthn/src/transport/cable/channel.rs @@ -125,6 +125,10 @@ impl From for CableUxUpdate { impl Channel for CableChannel { type UxUpdate = CableUxUpdate; + fn transport(&self) -> Option { + Some(crate::Transport::Hybrid) + } + async fn supported_protocols(&self) -> Result { Ok(SupportedProtocols::fido2_only()) } diff --git a/libwebauthn/src/transport/channel.rs b/libwebauthn/src/transport/channel.rs index 5860755f..d4c166bd 100644 --- a/libwebauthn/src/transport/channel.rs +++ b/libwebauthn/src/transport/channel.rs @@ -64,6 +64,12 @@ pub trait Channel: Send + Sync + Display + Ctap2AuthTokenStore { async fn status(&self) -> ChannelStatus; async fn close(&mut self); + /// The transport this channel speaks over, used to populate the registration + /// response `transports` member. `None` means unknown. + fn transport(&self) -> Option { + None + } + async fn apdu_send(&mut self, request: &ApduRequest, timeout: Duration) -> Result<(), Error>; async fn apdu_recv(&mut self, timeout: Duration) -> Result; diff --git a/libwebauthn/src/transport/hid/channel.rs b/libwebauthn/src/transport/hid/channel.rs index 3cfbb5ab..4cab2163 100644 --- a/libwebauthn/src/transport/hid/channel.rs +++ b/libwebauthn/src/transport/hid/channel.rs @@ -505,6 +505,10 @@ impl Display for HidChannel<'_> { impl Channel for HidChannel<'_> { type UxUpdate = UvUpdate; + fn transport(&self) -> Option { + Some(crate::Transport::Usb) + } + async fn supported_protocols(&self) -> Result { let cbor_supported = self.init.caps.contains(Caps::CBOR); let apdu_supported = !self.init.caps.contains(Caps::NO_MSG); diff --git a/libwebauthn/src/transport/nfc/channel.rs b/libwebauthn/src/transport/nfc/channel.rs index 33cf532e..4960563b 100644 --- a/libwebauthn/src/transport/nfc/channel.rs +++ b/libwebauthn/src/transport/nfc/channel.rs @@ -259,6 +259,10 @@ where { type UxUpdate = UvUpdate; + fn transport(&self) -> Option { + Some(crate::Transport::Nfc) + } + async fn supported_protocols(&self) -> Result { Ok(self.supported) } From 661d3b1511f44f4d20adc556af68240708de6bd0 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Mon, 15 Jun 2026 23:28:58 +0100 Subject: [PATCH 2/5] refactor(webauthn): stamp transport on the response instead of threading it --- libwebauthn/examples/ceremony/webauthn_ble.rs | 12 ++------- .../examples/ceremony/webauthn_cable.rs | 2 +- .../examples/ceremony/webauthn_cable_wss.rs | 4 +-- libwebauthn/examples/ceremony/webauthn_hid.rs | 12 ++------- libwebauthn/examples/ceremony/webauthn_nfc.rs | 8 ++---- .../examples/features/webauthn_prf_cable.rs | 4 +-- .../features/webauthn_related_origins_hid.rs | 2 +- libwebauthn/src/ops/webauthn/get_assertion.rs | 13 +++++----- libwebauthn/src/ops/webauthn/idl/response.rs | 11 +++----- .../src/ops/webauthn/make_credential.rs | 26 +++++++++++-------- .../src/proto/ctap2/model/get_assertion.rs | 4 +-- .../src/proto/ctap2/model/make_credential.rs | 1 + libwebauthn/src/webauthn.rs | 6 +++-- 13 files changed, 43 insertions(+), 62 deletions(-) diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs index e54025da..981214ef 100644 --- a/libwebauthn/examples/ceremony/webauthn_ble.rs +++ b/libwebauthn/examples/ceremony/webauthn_ble.rs @@ -75,11 +75,7 @@ pub async fn main() -> Result<(), Box> { .unwrap(); println!("WebAuthn MakeCredential response: {:?}", response); - match response.to_json_string( - &make_credentials_request, - channel.transport(), - JsonFormat::Prettified, - ) { + match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) { Ok(response_json) => { println!( "WebAuthn MakeCredential response (JSON):\n{}", @@ -117,11 +113,7 @@ pub async fn main() -> Result<(), Box> { println!("WebAuthn GetAssertion response: {:?}", response); for assertion in &response.assertions { - match assertion.to_json_string( - &get_assertion, - channel.transport(), - JsonFormat::Prettified, - ) { + match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) { Ok(assertion_json) => { println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json); } diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs index a4d5985b..975c3a67 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable.rs @@ -92,7 +92,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, channel.transport(), JsonFormat::Prettified) + .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs index b3c82052..e9555583 100644 --- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs +++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs @@ -110,7 +110,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, channel.transport(), JsonFormat::Prettified) + .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); } @@ -182,7 +182,7 @@ async fn run_get_assertion( let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap(); for assertion in &response.assertions { let assertion_json = assertion - .to_json_string(&request, channel.transport(), JsonFormat::Prettified) + .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize GetAssertion response"); println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}"); } diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs index 5939b9c3..6e7ec823 100644 --- a/libwebauthn/examples/ceremony/webauthn_hid.rs +++ b/libwebauthn/examples/ceremony/webauthn_hid.rs @@ -79,11 +79,7 @@ pub async fn main() -> Result<(), Box> { .unwrap(); println!("WebAuthn MakeCredential response: {:?}", response); - match response.to_json_string( - &make_credentials_request, - channel.transport(), - JsonFormat::Prettified, - ) { + match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) { Ok(response_json) => { println!( "WebAuthn MakeCredential response (JSON):\n{}", @@ -121,11 +117,7 @@ pub async fn main() -> Result<(), Box> { println!("WebAuthn GetAssertion response: {:?}", response); for assertion in &response.assertions { - match assertion.to_json_string( - &get_assertion, - channel.transport(), - JsonFormat::Prettified, - ) { + match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) { Ok(assertion_json) => { println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json); } diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs index 0d88e443..f3d27654 100644 --- a/libwebauthn/examples/ceremony/webauthn_nfc.rs +++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs @@ -77,11 +77,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&make_credentials_request)).unwrap(); let response_json = response - .to_json_string( - &make_credentials_request, - channel.transport(), - JsonFormat::Prettified, - ) + .to_json_string(&make_credentials_request, JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); @@ -104,7 +100,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap(); for assertion in &response.assertions { let assertion_json = assertion - .to_json_string(&get_assertion, channel.transport(), JsonFormat::Prettified) + .to_json_string(&get_assertion, JsonFormat::Prettified) .expect("Failed to serialize GetAssertion response"); println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}"); } diff --git a/libwebauthn/examples/features/webauthn_prf_cable.rs b/libwebauthn/examples/features/webauthn_prf_cable.rs index 98c7d162..c375ff41 100644 --- a/libwebauthn/examples/features/webauthn_prf_cable.rs +++ b/libwebauthn/examples/features/webauthn_prf_cable.rs @@ -124,7 +124,7 @@ async fn create() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, channel.transport(), JsonFormat::Prettified) + .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); @@ -191,7 +191,7 @@ async fn get(credential_id: Option<&str>) -> Result<(), Box> { for (num, assertion) in response.assertions.iter().enumerate() { let assertion_json = assertion - .to_json_string(&request, channel.transport(), JsonFormat::Prettified) + .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize GetAssertion response"); println!("Assertion {num} (JSON):\n{assertion_json}"); } diff --git a/libwebauthn/examples/features/webauthn_related_origins_hid.rs b/libwebauthn/examples/features/webauthn_related_origins_hid.rs index adf1b38b..d83620f3 100644 --- a/libwebauthn/examples/features/webauthn_related_origins_hid.rs +++ b/libwebauthn/examples/features/webauthn_related_origins_hid.rs @@ -76,7 +76,7 @@ pub async fn main() -> Result<(), Box> { let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap(); let response_json = response - .to_json_string(&request, channel.transport(), JsonFormat::Prettified) + .to_json_string(&request, JsonFormat::Prettified) .expect("Failed to serialize MakeCredential response"); println!("WebAuthn MakeCredential response (JSON):\n{response_json}"); } diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs index 637dacef..2498044b 100644 --- a/libwebauthn/src/ops/webauthn/get_assertion.rs +++ b/libwebauthn/src/ops/webauthn/get_assertion.rs @@ -489,7 +489,6 @@ impl WebAuthnIDLResponse for Assertion { fn to_idl_model( &self, request: &Self::Context, - _transport: Option, ) -> Result { // Get credential ID - either from credential_id field or from authenticator_data let credential_id_bytes = self @@ -1307,7 +1306,7 @@ mod tests { let assertion = create_test_assertion(); let request = create_test_request(); - let json = assertion.to_json_string(&request, None, JsonFormat::default()); + let json = assertion.to_json_string(&request, JsonFormat::default()); assert!(json.is_ok()); let json_str = json.unwrap(); @@ -1333,7 +1332,7 @@ mod tests { fn test_assertion_to_idl_model() { let assertion = create_test_assertion(); let request = create_test_request(); - let model = assertion.to_idl_model(&request, None).unwrap(); + let model = assertion.to_idl_model(&request).unwrap(); // Verify the credential ID assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]); @@ -1355,7 +1354,7 @@ mod tests { )); let request = create_test_request(); - let model = assertion.to_idl_model(&request, None).unwrap(); + let model = assertion.to_idl_model(&request).unwrap(); // Verify user handle is present assert!(model.response.user_handle.is_some()); @@ -1382,7 +1381,7 @@ mod tests { }); let request = create_test_request(); - let model = assertion.to_idl_model(&request, None).unwrap(); + let model = assertion.to_idl_model(&request).unwrap(); // Verify extension outputs - PRF should be set with correct values let prf = model.client_extension_results.prf.as_ref().unwrap(); @@ -1404,7 +1403,7 @@ mod tests { }); let request = create_test_request(); - let model = assertion.to_idl_model(&request, None).unwrap(); + let model = assertion.to_idl_model(&request).unwrap(); assert_eq!(model.client_extension_results.appid, Some(true)); // The output should also round-trip through the JSON wire format. @@ -1428,7 +1427,7 @@ mod tests { }); let request = create_test_request(); - let model = assertion.to_idl_model(&request, None).unwrap(); + let model = assertion.to_idl_model(&request).unwrap(); assert_eq!(model.client_extension_results.appid, None); let json = serde_json::to_value(&model.client_extension_results).unwrap(); diff --git a/libwebauthn/src/ops/webauthn/idl/response.rs b/libwebauthn/src/ops/webauthn/idl/response.rs index 9972b4bf..c975aaa9 100644 --- a/libwebauthn/src/ops/webauthn/idl/response.rs +++ b/libwebauthn/src/ops/webauthn/idl/response.rs @@ -55,22 +55,18 @@ pub trait WebAuthnIDLResponse: Sized { /// Context required for serialization (e.g., client data JSON). type Context; - /// Converts this response to a JSON-serializable IDL model. `transport` is the - /// transport the ceremony ran over, used to populate the registration - /// `transports` member. Pass `None` when it is unknown. + /// Converts this response to a JSON-serializable IDL model. fn to_idl_model( &self, ctx: &Self::Context, - transport: Option, ) -> Result; /// Serializes this response to a `serde_json::Value`. fn to_json_value( &self, ctx: &Self::Context, - transport: Option, ) -> Result { - let model = self.to_idl_model(ctx, transport)?; + let model = self.to_idl_model(ctx)?; Ok(serde_json::to_value(&model)?) } @@ -78,10 +74,9 @@ pub trait WebAuthnIDLResponse: Sized { fn to_json_string( &self, ctx: &Self::Context, - transport: Option, format: JsonFormat, ) -> Result { - let value = self.to_json_value(ctx, transport)?; + let value = self.to_json_value(ctx)?; match format { JsonFormat::Minified => Ok(serde_json::to_string(&value)?), JsonFormat::Prettified => Ok(serde_json::to_string_pretty(&value)?), diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 367c6190..99da3221 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -47,6 +47,8 @@ pub struct MakeCredentialResponse { pub enterprise_attestation: Option, pub large_blob_key: Option>, pub unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions, + /// Transport the credential was created over, stamped by the channel. + pub transport: Option, } /// Serializable attestation object for CBOR encoding. @@ -82,7 +84,6 @@ impl WebAuthnIDLResponse for MakeCredentialResponse { fn to_idl_model( &self, request: &Self::Context, - transport: Option, ) -> Result { // The AT flag MUST be set on makeCredential responses per CTAP 2.2 §6.1. let attested = self @@ -118,7 +119,7 @@ impl WebAuthnIDLResponse for MakeCredentialResponse { // Build attestation object (CBOR map with authData, fmt, attStmt) let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?; - let transports = registration_transports(transport); + let transports = registration_transports(self.transport); // Build client extension results let client_extension_results = self.build_client_extension_results(); @@ -1424,6 +1425,7 @@ mod tests { enterprise_attestation: None, large_blob_key: None, unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions::default(), + transport: None, } } @@ -1449,7 +1451,7 @@ mod tests { let response = create_test_response(); let request = create_test_request(); - let json = response.to_json_string(&request, None, JsonFormat::default()); + let json = response.to_json_string(&request, JsonFormat::default()); assert!(json.is_ok()); let json_str = json.unwrap(); @@ -1504,7 +1506,7 @@ mod tests { fn test_response_to_idl_model() { let response = create_test_response(); let request = create_test_request(); - let model = response.to_idl_model(&request, None).unwrap(); + let model = response.to_idl_model(&request).unwrap(); // Verify the credential ID assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]); @@ -1523,7 +1525,7 @@ mod tests { // WebAuthn L3 §5.2.1.1: the registration `transports` member reports the // transport the credential was created over, as AuthenticatorTransport tokens. // Both the FIDO2 and U2F-downgrade paths converge on this serialization. - let response = create_test_response(); + let mut response = create_test_response(); let request = create_test_request(); for (transport, token) in [ @@ -1532,15 +1534,16 @@ mod tests { (crate::Transport::Nfc, "nfc"), (crate::Transport::Hybrid, "hybrid"), ] { - let model = response.to_idl_model(&request, Some(transport)).unwrap(); + response.transport = Some(transport); + let model = response.to_idl_model(&request).unwrap(); assert_eq!(model.response.transports, vec![token.to_string()]); } // The token reaches the JSON wire format too. + response.transport = Some(crate::Transport::Nfc); let json = response .to_json_string( &request, - Some(crate::Transport::Nfc), crate::ops::webauthn::idl::response::JsonFormat::default(), ) .unwrap(); @@ -1549,7 +1552,8 @@ mod tests { assert_eq!(transports, &vec![serde_json::Value::from("nfc")]); // An unknown transport leaves the list empty. - let model = response.to_idl_model(&request, None).unwrap(); + response.transport = None; + let model = response.to_idl_model(&request).unwrap(); assert!(model.response.transports.is_empty()); } @@ -1562,7 +1566,7 @@ mod tests { // by the secp256r1 OID and the uncompressed point. let response = create_test_response(); let request = create_test_request(); - let model = response.to_idl_model(&request, None).unwrap(); + let model = response.to_idl_model(&request).unwrap(); let public_key_bytes = model .response @@ -1586,7 +1590,7 @@ mod tests { fn test_response_attestation_object_format() { let response = create_test_response(); let request = create_test_request(); - let model = response.to_idl_model(&request, None).unwrap(); + let model = response.to_idl_model(&request).unwrap(); // Decode the attestation object let attestation_bytes = model.response.attestation_object.0; @@ -1631,7 +1635,7 @@ mod tests { }; let request = create_test_request(); - let model = response.to_idl_model(&request, None).unwrap(); + let model = response.to_idl_model(&request).unwrap(); // Verify cred_props extension let cred_props = model.client_extension_results.cred_props.as_ref().unwrap(); diff --git a/libwebauthn/src/proto/ctap2/model/get_assertion.rs b/libwebauthn/src/proto/ctap2/model/get_assertion.rs index b4f769e1..08c60fa6 100644 --- a/libwebauthn/src/proto/ctap2/model/get_assertion.rs +++ b/libwebauthn/src/proto/ctap2/model/get_assertion.rs @@ -1337,7 +1337,7 @@ mod tests { ); let assertion = parsed.into_assertion_output(&request, None); let json_str = assertion - .to_json_string(&request, None, JsonFormat::default()) + .to_json_string(&request, JsonFormat::default()) .unwrap(); let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -1442,7 +1442,7 @@ mod tests { let request = make_request(vec![make_credential(b"cred-1")]); let assertion = parsed.into_assertion_output(&request, None); let json_str = assertion - .to_json_string(&request, None, JsonFormat::default()) + .to_json_string(&request, JsonFormat::default()) .unwrap(); let json: serde_json::Value = serde_json::from_str(&json_str).unwrap(); diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index ddfaaca6..8a153689 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -404,6 +404,7 @@ impl Ctap2MakeCredentialResponse { enterprise_attestation: self.enterprise_attestation, large_blob_key: self.large_blob_key.map(|x| x.into_vec()), unsigned_extensions_output, + transport: None, } } } diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 75f18a03..cf24720a 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -137,10 +137,12 @@ where }; trace!(?op, "WebAuthn MakeCredential request"); let protocol = negotiate_protocol(self, op.is_downgradable()).await?; - match protocol { + let mut response = match protocol { FidoProtocol::FIDO2 => make_credential_fido2(self, op).await, FidoProtocol::U2F => make_credential_u2f(self, op).await, - } + }?; + response.transport = self.transport(); + Ok(response) } #[instrument(skip_all, fields(dev = % self))] From 3652b6ef781e38589faf94c738fada3532f11cce Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 16 Jun 2026 19:17:40 +0100 Subject: [PATCH 3/5] feat(webauthn): populate registration transports from authenticator getInfo --- .../src/ops/webauthn/make_credential.rs | 51 ++++++++++++++++++- .../src/proto/ctap2/model/make_credential.rs | 1 + 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 99da3221..e266c1b6 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -49,6 +49,8 @@ pub struct MakeCredentialResponse { pub unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions, /// Transport the credential was created over, stamped by the channel. pub transport: Option, + /// Transports the authenticator advertised in getInfo (0x09), if any. + pub authenticator_transports: Option>, } /// Serializable attestation object for CBOR encoding. @@ -119,7 +121,17 @@ impl WebAuthnIDLResponse for MakeCredentialResponse { // Build attestation object (CBOR map with authData, fmt, attStmt) let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?; - let transports = registration_transports(self.transport); + // Prefer the authenticator's getInfo 0x09 transports; else the ceremony + // transport. WebAuthn L3 §5.2.1.1: unique tokens, lexicographically sorted. + let transports = match self.authenticator_transports.as_ref() { + Some(reported) if !reported.is_empty() => { + let mut tokens = reported.clone(); + tokens.sort(); + tokens.dedup(); + tokens + } + _ => registration_transports(self.transport), + }; // Build client extension results let client_extension_results = self.build_client_extension_results(); @@ -1426,6 +1438,7 @@ mod tests { large_blob_key: None, unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions::default(), transport: None, + authenticator_transports: None, } } @@ -1557,6 +1570,42 @@ mod tests { assert!(model.response.transports.is_empty()); } + #[test] + fn test_response_to_idl_model_transports_from_get_info() { + // The authenticator's getInfo (0x09) transports source the registration + // `transports` member as unique tokens in lexicographical order, taking + // precedence over the single ceremony transport (no union). + let mut response = create_test_response(); + let request = create_test_request(); + + // Reported out of order with a duplicate; ceremony transport differs. + response.transport = Some(crate::Transport::Ble); + response.authenticator_transports = Some(vec![ + "usb".to_string(), + "nfc".to_string(), + "usb".to_string(), + ]); + let model = response.to_idl_model(&request).unwrap(); + assert_eq!( + model.response.transports, + vec!["nfc".to_string(), "usb".to_string()] + ); + + // An empty reported list falls back to the ceremony transport. + response.authenticator_transports = Some(Vec::new()); + let model = response.to_idl_model(&request).unwrap(); + assert_eq!(model.response.transports, vec!["ble".to_string()]); + + // Unknown tokens pass through unchanged. + response.authenticator_transports = + Some(vec!["smart-card".to_string(), "custom".to_string()]); + let model = response.to_idl_model(&request).unwrap(); + assert_eq!( + model.response.transports, + vec!["custom".to_string(), "smart-card".to_string()] + ); + } + #[test] fn test_response_emits_spki_for_es256() { // The test fixture builds an ES256 P-256 credential, so getPublicKey() diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index 8a153689..d6fe951e 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -405,6 +405,7 @@ impl Ctap2MakeCredentialResponse { large_blob_key: self.large_blob_key.map(|x| x.into_vec()), unsigned_extensions_output, transport: None, + authenticator_transports: info.and_then(|i| i.transports.clone()), } } } From 61ad0af4b30bdada7ea1623bed014f7833bc6207 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 16 Jun 2026 19:25:06 +0100 Subject: [PATCH 4/5] refactor(webauthn): fold the ceremony transport into the transports list --- .../src/ops/webauthn/make_credential.rs | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index e266c1b6..80d107b4 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -121,17 +121,12 @@ impl WebAuthnIDLResponse for MakeCredentialResponse { // Build attestation object (CBOR map with authData, fmt, attStmt) let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?; - // Prefer the authenticator's getInfo 0x09 transports; else the ceremony - // transport. WebAuthn L3 §5.2.1.1: unique tokens, lexicographically sorted. - let transports = match self.authenticator_transports.as_ref() { - Some(reported) if !reported.is_empty() => { - let mut tokens = reported.clone(); - tokens.sort(); - tokens.dedup(); - tokens - } - _ => registration_transports(self.transport), - }; + // WebAuthn getTransports(): the authenticator's getInfo 0x09 transports + // folded with the ceremony transport, unique tokens lexicographically sorted. + let mut transports = self.authenticator_transports.clone().unwrap_or_default(); + transports.extend(registration_transports(self.transport)); + transports.sort(); + transports.dedup(); // Build client extension results let client_extension_results = self.build_client_extension_results(); @@ -1572,13 +1567,12 @@ mod tests { #[test] fn test_response_to_idl_model_transports_from_get_info() { - // The authenticator's getInfo (0x09) transports source the registration - // `transports` member as unique tokens in lexicographical order, taking - // precedence over the single ceremony transport (no union). + // The authenticator's getInfo (0x09) transports are folded with the + // ceremony transport, as unique tokens in lexicographical order. let mut response = create_test_response(); let request = create_test_request(); - // Reported out of order with a duplicate; ceremony transport differs. + // Reported out of order with a duplicate; the ceremony transport (ble) folds in. response.transport = Some(crate::Transport::Ble); response.authenticator_transports = Some(vec![ "usb".to_string(), @@ -1586,23 +1580,37 @@ mod tests { "usb".to_string(), ]); let model = response.to_idl_model(&request).unwrap(); + assert_eq!( + model.response.transports, + vec!["ble".to_string(), "nfc".to_string(), "usb".to_string()] + ); + + // A ceremony transport already in the reported list is not duplicated. + response.transport = Some(crate::Transport::Usb); + response.authenticator_transports = Some(vec!["usb".to_string(), "nfc".to_string()]); + let model = response.to_idl_model(&request).unwrap(); assert_eq!( model.response.transports, vec!["nfc".to_string(), "usb".to_string()] ); - // An empty reported list falls back to the ceremony transport. - response.authenticator_transports = Some(Vec::new()); + // No reported transports leaves just the ceremony transport. + response.authenticator_transports = None; let model = response.to_idl_model(&request).unwrap(); - assert_eq!(model.response.transports, vec!["ble".to_string()]); + assert_eq!(model.response.transports, vec!["usb".to_string()]); - // Unknown tokens pass through unchanged. + // Unknown tokens pass through, folded with the ceremony transport. + response.transport = Some(crate::Transport::Ble); response.authenticator_transports = Some(vec!["smart-card".to_string(), "custom".to_string()]); let model = response.to_idl_model(&request).unwrap(); assert_eq!( model.response.transports, - vec!["custom".to_string(), "smart-card".to_string()] + vec![ + "ble".to_string(), + "custom".to_string(), + "smart-card".to_string() + ] ); } From c9654de229ea3e0606d553f9e8a8ded121c9ea73 Mon Sep 17 00:00:00 2001 From: Alfie Fresta Date: Tue, 16 Jun 2026 19:55:14 +0100 Subject: [PATCH 5/5] refactor(transport): probe real availability and simplify transport() --- libwebauthn/src/lib.rs | 41 ++++++++----------- .../src/ops/webauthn/make_credential.rs | 21 +++++----- libwebauthn/src/proto/ctap2/model.rs | 13 +++--- libwebauthn/src/transport/ble/channel.rs | 5 ++- libwebauthn/src/transport/cable/channel.rs | 5 ++- libwebauthn/src/transport/channel.rs | 7 ++-- libwebauthn/src/transport/hid/channel.rs | 5 ++- libwebauthn/src/transport/mod.rs | 7 ++++ libwebauthn/src/transport/nfc/channel.rs | 5 ++- libwebauthn/src/webauthn.rs | 2 +- 10 files changed, 58 insertions(+), 53 deletions(-) diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs index 0f4c5a4d..e1f3c64e 100644 --- a/libwebauthn/src/lib.rs +++ b/libwebauthn/src/lib.rs @@ -215,36 +215,27 @@ impl PartialEq for PinNotSetUpdate { } } -/// Transports compiled into this build. Hybrid/caBLE is always included. Using it -/// at runtime still needs a BLE adapter (see `transport::cable::is_available`). -/// NFC appears only when an `nfc-backend-*` feature is enabled. -pub fn available_transports() -> Vec { - [ - Transport::Usb, - Transport::Ble, - Transport::Hybrid, - #[cfg(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc"))] - Transport::Nfc, - ] - .into_iter() - .collect() +/// The transports usable right now. USB is always available. BLE and Hybrid +/// (caBLE) need a Bluetooth adapter. NFC needs a reader and a compiled backend. +pub async fn available_transports() -> Vec { + let mut transports = vec![Transport::Usb]; + // BLE and Hybrid (caBLE) both need a Bluetooth adapter. + if transport::ble::is_available().await { + transports.push(Transport::Ble); + transports.push(Transport::Hybrid); + } + if transport::nfc::is_nfc_available() { + transports.push(Transport::Nfc); + } + transports } #[cfg(test)] mod tests { use super::*; - #[test] - fn available_transports_reports_compiled_in() { - let transports = available_transports(); - assert!(transports.contains(&Transport::Usb)); - assert!(transports.contains(&Transport::Ble)); - assert!(transports.contains(&Transport::Hybrid)); - - let nfc_compiled = cfg!(any( - feature = "nfc-backend-pcsc", - feature = "nfc-backend-libnfc" - )); - assert_eq!(transports.contains(&Transport::Nfc), nfc_compiled); + #[tokio::test] + async fn available_transports_always_includes_usb() { + assert!(available_transports().await.contains(&Transport::Usb)); } } diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs index 80d107b4..e2e7f412 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -34,6 +34,7 @@ use crate::{ }, }, transport::AuthTokenData, + Transport, }; use super::timeout::DEFAULT_TIMEOUT; @@ -48,7 +49,7 @@ pub struct MakeCredentialResponse { pub large_blob_key: Option>, pub unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions, /// Transport the credential was created over, stamped by the channel. - pub transport: Option, + pub transport: Option, /// Transports the authenticator advertised in getInfo (0x09), if any. pub authenticator_transports: Option>, } @@ -67,7 +68,7 @@ struct AttestationObject<'a> { /// Maps the active transport to AuthenticatorTransport tokens for the registration /// `transports` member. The list is deduplicated and lexicographically sorted per /// WebAuthn L3 §5.2.1.1, and is empty when the transport is unknown. -fn registration_transports(transport: Option) -> Vec { +fn registration_transports(transport: Option) -> Vec { let mut tokens: Vec = transport .into_iter() .map(Ctap2Transport::from) @@ -1537,10 +1538,10 @@ mod tests { let request = create_test_request(); for (transport, token) in [ - (crate::Transport::Usb, "usb"), - (crate::Transport::Ble, "ble"), - (crate::Transport::Nfc, "nfc"), - (crate::Transport::Hybrid, "hybrid"), + (Transport::Usb, "usb"), + (Transport::Ble, "ble"), + (Transport::Nfc, "nfc"), + (Transport::Hybrid, "hybrid"), ] { response.transport = Some(transport); let model = response.to_idl_model(&request).unwrap(); @@ -1548,7 +1549,7 @@ mod tests { } // The token reaches the JSON wire format too. - response.transport = Some(crate::Transport::Nfc); + response.transport = Some(Transport::Nfc); let json = response .to_json_string( &request, @@ -1573,7 +1574,7 @@ mod tests { let request = create_test_request(); // Reported out of order with a duplicate; the ceremony transport (ble) folds in. - response.transport = Some(crate::Transport::Ble); + response.transport = Some(Transport::Ble); response.authenticator_transports = Some(vec![ "usb".to_string(), "nfc".to_string(), @@ -1586,7 +1587,7 @@ mod tests { ); // A ceremony transport already in the reported list is not duplicated. - response.transport = Some(crate::Transport::Usb); + response.transport = Some(Transport::Usb); response.authenticator_transports = Some(vec!["usb".to_string(), "nfc".to_string()]); let model = response.to_idl_model(&request).unwrap(); assert_eq!( @@ -1600,7 +1601,7 @@ mod tests { assert_eq!(model.response.transports, vec!["usb".to_string()]); // Unknown tokens pass through, folded with the ceremony transport. - response.transport = Some(crate::Transport::Ble); + response.transport = Some(Transport::Ble); response.authenticator_transports = Some(vec!["smart-card".to_string(), "custom".to_string()]); let model = response.to_idl_model(&request).unwrap(); diff --git a/libwebauthn/src/proto/ctap2/model.rs b/libwebauthn/src/proto/ctap2/model.rs index 3d502413..a8e86e4f 100644 --- a/libwebauthn/src/proto/ctap2/model.rs +++ b/libwebauthn/src/proto/ctap2/model.rs @@ -1,4 +1,5 @@ use crate::proto::ctap1::Ctap1Transport; +use crate::Transport; use crate::{ ops::webauthn::idl::create::PublicKeyCredentialUserEntity, pin::PinUvAuthProtocol, webauthn::Error, @@ -167,13 +168,13 @@ impl From<&Ctap1Transport> for Ctap2Transport { } } -impl From for Ctap2Transport { - fn from(transport: crate::Transport) -> Ctap2Transport { +impl From for Ctap2Transport { + fn from(transport: Transport) -> Ctap2Transport { match transport { - crate::Transport::Usb => Ctap2Transport::Usb, - crate::Transport::Ble => Ctap2Transport::Ble, - crate::Transport::Nfc => Ctap2Transport::Nfc, - crate::Transport::Hybrid => Ctap2Transport::Hybrid, + Transport::Usb => Ctap2Transport::Usb, + Transport::Ble => Ctap2Transport::Ble, + Transport::Nfc => Ctap2Transport::Nfc, + Transport::Hybrid => Ctap2Transport::Hybrid, } } } diff --git a/libwebauthn/src/transport/ble/channel.rs b/libwebauthn/src/transport/ble/channel.rs index 77029ca6..fbfa2d7f 100644 --- a/libwebauthn/src/transport/ble/channel.rs +++ b/libwebauthn/src/transport/ble/channel.rs @@ -15,6 +15,7 @@ use crate::transport::channel::{ use crate::transport::device::SupportedProtocols; use crate::transport::error::TransportError; use crate::webauthn::error::Error; +use crate::Transport; use crate::UvUpdate; use super::btleplug::manager::SupportedRevisions; @@ -81,8 +82,8 @@ impl Display for BleChannel<'_> { impl<'a> Channel for BleChannel<'a> { type UxUpdate = UvUpdate; - fn transport(&self) -> Option { - Some(crate::Transport::Ble) + fn transport(&self) -> Transport { + Transport::Ble } async fn supported_protocols(&self) -> Result { diff --git a/libwebauthn/src/transport/cable/channel.rs b/libwebauthn/src/transport/cable/channel.rs index 97127c8b..852f4c32 100644 --- a/libwebauthn/src/transport/cable/channel.rs +++ b/libwebauthn/src/transport/cable/channel.rs @@ -18,6 +18,7 @@ use crate::transport::{ channel::ChannelStatus, device::SupportedProtocols, Channel, Ctap2AuthTokenStore, }; use crate::webauthn::error::Error; +use crate::Transport; use crate::UvUpdate; use super::known_devices::CableKnownDevice; @@ -125,8 +126,8 @@ impl From for CableUxUpdate { impl Channel for CableChannel { type UxUpdate = CableUxUpdate; - fn transport(&self) -> Option { - Some(crate::Transport::Hybrid) + fn transport(&self) -> Transport { + Transport::Hybrid } async fn supported_protocols(&self) -> Result { diff --git a/libwebauthn/src/transport/channel.rs b/libwebauthn/src/transport/channel.rs index d4c166bd..1f3fec6e 100644 --- a/libwebauthn/src/transport/channel.rs +++ b/libwebauthn/src/transport/channel.rs @@ -11,6 +11,7 @@ use crate::proto::{ ctap2::cbor::{CborRequest, CborResponse}, }; use crate::webauthn::error::Error; +use crate::Transport; use crate::UvUpdate; use async_trait::async_trait; @@ -65,9 +66,9 @@ pub trait Channel: Send + Sync + Display + Ctap2AuthTokenStore { async fn close(&mut self); /// The transport this channel speaks over, used to populate the registration - /// response `transports` member. `None` means unknown. - fn transport(&self) -> Option { - None + /// response `transports` member. + fn transport(&self) -> Transport { + Transport::Usb } async fn apdu_send(&mut self, request: &ApduRequest, timeout: Duration) -> Result<(), Error>; diff --git a/libwebauthn/src/transport/hid/channel.rs b/libwebauthn/src/transport/hid/channel.rs index 4cab2163..4a1778df 100644 --- a/libwebauthn/src/transport/hid/channel.rs +++ b/libwebauthn/src/transport/hid/channel.rs @@ -34,6 +34,7 @@ use crate::transport::hid::framing::{ HidCommand, HidMessage, HidMessageParser, HidMessageParserState, }; use crate::webauthn::error::{Error, PlatformError}; +use crate::Transport; use crate::UvUpdate; use super::device::get_hidapi; @@ -505,8 +506,8 @@ impl Display for HidChannel<'_> { impl Channel for HidChannel<'_> { type UxUpdate = UvUpdate; - fn transport(&self) -> Option { - Some(crate::Transport::Usb) + fn transport(&self) -> Transport { + Transport::Usb } async fn supported_protocols(&self) -> Result { diff --git a/libwebauthn/src/transport/mod.rs b/libwebauthn/src/transport/mod.rs index cdd410de..76a79284 100644 --- a/libwebauthn/src/transport/mod.rs +++ b/libwebauthn/src/transport/mod.rs @@ -23,6 +23,13 @@ pub mod hid; pub mod mock; #[cfg(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc"))] pub mod nfc; +// No NFC backend compiled: a stub so callers need not gate on the feature. +#[cfg(not(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc")))] +pub mod nfc { + pub fn is_nfc_available() -> bool { + false + } +} mod channel; #[allow(clippy::module_inception)] diff --git a/libwebauthn/src/transport/nfc/channel.rs b/libwebauthn/src/transport/nfc/channel.rs index 4960563b..6b448e48 100644 --- a/libwebauthn/src/transport/nfc/channel.rs +++ b/libwebauthn/src/transport/nfc/channel.rs @@ -21,6 +21,7 @@ use crate::transport::channel::{ use crate::transport::device::SupportedProtocols; use crate::transport::error::TransportError; use crate::webauthn::Error; +use crate::Transport; use crate::UvUpdate; use super::commands::{command_ctap_msg, command_get_response}; @@ -259,8 +260,8 @@ where { type UxUpdate = UvUpdate; - fn transport(&self) -> Option { - Some(crate::Transport::Nfc) + fn transport(&self) -> Transport { + Transport::Nfc } async fn supported_protocols(&self) -> Result { diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index cf24720a..cfee2ac4 100644 --- a/libwebauthn/src/webauthn.rs +++ b/libwebauthn/src/webauthn.rs @@ -141,7 +141,7 @@ where FidoProtocol::FIDO2 => make_credential_fido2(self, op).await, FidoProtocol::U2F => make_credential_u2f(self, op).await, }?; - response.transport = self.transport(); + response.transport = Some(self.transport()); Ok(response) }