diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs index 6b64e279..e1f3c64e 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,27 @@ impl PartialEq for PinNotSetUpdate { } } -pub fn available_transports() -> Vec { - vec![Transport::Usb, Transport::Ble] +/// 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::*; + + #[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 a75db158..e2e7f412 100644 --- a/libwebauthn/src/ops/webauthn/make_credential.rs +++ b/libwebauthn/src/ops/webauthn/make_credential.rs @@ -29,11 +29,12 @@ use crate::{ cbor, cbor::Value, cose, parse_unsigned_prf, Ctap2AttestationStatement, Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2GetInfoResponse, Ctap2MakeCredentialsResponseExtensions, Ctap2PublicKeyCredentialDescriptor, - Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, + Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, Ctap2Transport, UnsignedPrfOutput, }, }, transport::AuthTokenData, + Transport, }; use super::timeout::DEFAULT_TIMEOUT; @@ -47,6 +48,10 @@ 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, + /// Transports the authenticator advertised in getInfo (0x09), if any. + pub authenticator_transports: Option>, } /// Serializable attestation object for CBOR encoding. @@ -60,6 +65,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; @@ -102,8 +122,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)?; - // Get transports (we don't have direct access, so return empty for now) - let transports = Vec::new(); + // 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(); @@ -1409,6 +1433,8 @@ mod tests { enterprise_attestation: None, large_blob_key: None, unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions::default(), + transport: None, + authenticator_transports: None, } } @@ -1503,6 +1529,92 @@ 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 mut response = create_test_response(); + let request = create_test_request(); + + for (transport, token) in [ + (Transport::Usb, "usb"), + (Transport::Ble, "ble"), + (Transport::Nfc, "nfc"), + (Transport::Hybrid, "hybrid"), + ] { + 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(Transport::Nfc); + let json = response + .to_json_string( + &request, + 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. + response.transport = None; + let model = response.to_idl_model(&request).unwrap(); + assert!(model.response.transports.is_empty()); + } + + #[test] + fn test_response_to_idl_model_transports_from_get_info() { + // 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; the ceremony transport (ble) folds in. + response.transport = Some(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!["ble".to_string(), "nfc".to_string(), "usb".to_string()] + ); + + // A ceremony transport already in the reported list is not duplicated. + 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!( + model.response.transports, + vec!["nfc".to_string(), "usb".to_string()] + ); + + // 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!["usb".to_string()]); + + // Unknown tokens pass through, folded with the ceremony transport. + 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(); + assert_eq!( + model.response.transports, + vec![ + "ble".to_string(), + "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.rs b/libwebauthn/src/proto/ctap2/model.rs index d7b8e4b0..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,6 +168,17 @@ impl From<&Ctap1Transport> for Ctap2Transport { } } +impl From for Ctap2Transport { + fn from(transport: Transport) -> Ctap2Transport { + match transport { + Transport::Usb => Ctap2Transport::Usb, + Transport::Ble => Ctap2Transport::Ble, + Transport::Nfc => Ctap2Transport::Nfc, + Transport::Hybrid => Ctap2Transport::Hybrid, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Ctap2PublicKeyCredentialDescriptor { pub id: ByteBuf, diff --git a/libwebauthn/src/proto/ctap2/model/make_credential.rs b/libwebauthn/src/proto/ctap2/model/make_credential.rs index ddfaaca6..d6fe951e 100644 --- a/libwebauthn/src/proto/ctap2/model/make_credential.rs +++ b/libwebauthn/src/proto/ctap2/model/make_credential.rs @@ -404,6 +404,8 @@ impl Ctap2MakeCredentialResponse { enterprise_attestation: self.enterprise_attestation, 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()), } } } diff --git a/libwebauthn/src/transport/ble/channel.rs b/libwebauthn/src/transport/ble/channel.rs index 5ccc3dce..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,6 +82,10 @@ impl Display for BleChannel<'_> { impl<'a> Channel for BleChannel<'a> { type UxUpdate = UvUpdate; + fn transport(&self) -> Transport { + 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..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,6 +126,10 @@ impl From for CableUxUpdate { impl Channel for CableChannel { type UxUpdate = CableUxUpdate; + fn transport(&self) -> Transport { + 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..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; @@ -64,6 +65,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. + fn transport(&self) -> Transport { + Transport::Usb + } + 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..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,6 +506,10 @@ impl Display for HidChannel<'_> { impl Channel for HidChannel<'_> { type UxUpdate = UvUpdate; + fn transport(&self) -> Transport { + 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/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 33cf532e..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,6 +260,10 @@ where { type UxUpdate = UvUpdate; + fn transport(&self) -> Transport { + Transport::Nfc + } + async fn supported_protocols(&self) -> Result { Ok(self.supported) } diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs index 75f18a03..cfee2ac4 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 = Some(self.transport()); + Ok(response) } #[instrument(skip_all, fields(dev = % self))]