Skip to content

Commit 6749f91

Browse files
feat(transport): advertise NFC and hybrid transports (#282)
The library implements four transports but advertised only USB and BLE, and registration responses always returned an empty transports list. This adds NFC and hybrid to the public transport list, with NFC shown only when a backend is compiled in, and fills the registration transports from the transport actually used, ordered and deduplicated per the WebAuthn rules. Closes #258.
1 parent d4eb066 commit 6749f91

11 files changed

Lines changed: 193 additions & 8 deletions

File tree

libwebauthn/src/lib.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,12 @@ macro_rules! unwrap_field {
120120
use pin::{PinNotSetReason, PinRequestReason};
121121
pub(crate) use unwrap_field;
122122

123-
#[derive(Debug)]
123+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124124
pub enum Transport {
125125
Usb,
126126
Ble,
127+
Nfc,
128+
Hybrid,
127129
}
128130

129131
#[derive(Debug, Clone)]
@@ -213,6 +215,27 @@ impl PartialEq for PinNotSetUpdate {
213215
}
214216
}
215217

216-
pub fn available_transports() -> Vec<Transport> {
217-
vec![Transport::Usb, Transport::Ble]
218+
/// The transports usable right now. USB is always available. BLE and Hybrid
219+
/// (caBLE) need a Bluetooth adapter. NFC needs a reader and a compiled backend.
220+
pub async fn available_transports() -> Vec<Transport> {
221+
let mut transports = vec![Transport::Usb];
222+
// BLE and Hybrid (caBLE) both need a Bluetooth adapter.
223+
if transport::ble::is_available().await {
224+
transports.push(Transport::Ble);
225+
transports.push(Transport::Hybrid);
226+
}
227+
if transport::nfc::is_nfc_available() {
228+
transports.push(Transport::Nfc);
229+
}
230+
transports
231+
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
237+
#[tokio::test]
238+
async fn available_transports_always_includes_usb() {
239+
assert!(available_transports().await.contains(&Transport::Usb));
240+
}
218241
}

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ use crate::{
2929
cbor, cbor::Value, cose, parse_unsigned_prf, Ctap2AttestationStatement,
3030
Ctap2COSEAlgorithmIdentifier, Ctap2CredentialType, Ctap2GetInfoResponse,
3131
Ctap2MakeCredentialsResponseExtensions, Ctap2PublicKeyCredentialDescriptor,
32-
Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity,
32+
Ctap2PublicKeyCredentialRpEntity, Ctap2PublicKeyCredentialUserEntity, Ctap2Transport,
3333
UnsignedPrfOutput,
3434
},
3535
},
3636
transport::AuthTokenData,
37+
Transport,
3738
};
3839

3940
use super::timeout::DEFAULT_TIMEOUT;
@@ -47,6 +48,10 @@ pub struct MakeCredentialResponse {
4748
pub enterprise_attestation: Option<bool>,
4849
pub large_blob_key: Option<Vec<u8>>,
4950
pub unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions,
51+
/// Transport the credential was created over, stamped by the channel.
52+
pub transport: Option<Transport>,
53+
/// Transports the authenticator advertised in getInfo (0x09), if any.
54+
pub authenticator_transports: Option<Vec<String>>,
5055
}
5156

5257
/// Serializable attestation object for CBOR encoding.
@@ -60,6 +65,21 @@ struct AttestationObject<'a> {
6065
attestation_statement: &'a Ctap2AttestationStatement,
6166
}
6267

68+
/// Maps the active transport to AuthenticatorTransport tokens for the registration
69+
/// `transports` member. The list is deduplicated and lexicographically sorted per
70+
/// WebAuthn L3 §5.2.1.1, and is empty when the transport is unknown.
71+
fn registration_transports(transport: Option<Transport>) -> Vec<String> {
72+
let mut tokens: Vec<String> = transport
73+
.into_iter()
74+
.map(Ctap2Transport::from)
75+
.filter_map(|t| serde_json::to_value(t).ok())
76+
.filter_map(|v| v.as_str().map(str::to_owned))
77+
.collect();
78+
tokens.sort();
79+
tokens.dedup();
80+
tokens
81+
}
82+
6383
impl WebAuthnIDLResponse for MakeCredentialResponse {
6484
type IdlModel = RegistrationResponseJSON;
6585
type Context = MakeCredentialRequest;
@@ -102,8 +122,12 @@ impl WebAuthnIDLResponse for MakeCredentialResponse {
102122
// Build attestation object (CBOR map with authData, fmt, attStmt)
103123
let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?;
104124

105-
// Get transports (we don't have direct access, so return empty for now)
106-
let transports = Vec::new();
125+
// WebAuthn getTransports(): the authenticator's getInfo 0x09 transports
126+
// folded with the ceremony transport, unique tokens lexicographically sorted.
127+
let mut transports = self.authenticator_transports.clone().unwrap_or_default();
128+
transports.extend(registration_transports(self.transport));
129+
transports.sort();
130+
transports.dedup();
107131

108132
// Build client extension results
109133
let client_extension_results = self.build_client_extension_results();
@@ -1409,6 +1433,8 @@ mod tests {
14091433
enterprise_attestation: None,
14101434
large_blob_key: None,
14111435
unsigned_extensions_output: MakeCredentialsResponseUnsignedExtensions::default(),
1436+
transport: None,
1437+
authenticator_transports: None,
14121438
}
14131439
}
14141440

@@ -1503,6 +1529,92 @@ mod tests {
15031529
assert!(model.response.transports.is_empty());
15041530
}
15051531

1532+
#[test]
1533+
fn test_response_to_idl_model_populates_transports() {
1534+
// WebAuthn L3 §5.2.1.1: the registration `transports` member reports the
1535+
// transport the credential was created over, as AuthenticatorTransport tokens.
1536+
// Both the FIDO2 and U2F-downgrade paths converge on this serialization.
1537+
let mut response = create_test_response();
1538+
let request = create_test_request();
1539+
1540+
for (transport, token) in [
1541+
(Transport::Usb, "usb"),
1542+
(Transport::Ble, "ble"),
1543+
(Transport::Nfc, "nfc"),
1544+
(Transport::Hybrid, "hybrid"),
1545+
] {
1546+
response.transport = Some(transport);
1547+
let model = response.to_idl_model(&request).unwrap();
1548+
assert_eq!(model.response.transports, vec![token.to_string()]);
1549+
}
1550+
1551+
// The token reaches the JSON wire format too.
1552+
response.transport = Some(Transport::Nfc);
1553+
let json = response
1554+
.to_json_string(
1555+
&request,
1556+
crate::ops::webauthn::idl::response::JsonFormat::default(),
1557+
)
1558+
.unwrap();
1559+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1560+
let transports = parsed["response"]["transports"].as_array().unwrap();
1561+
assert_eq!(transports, &vec![serde_json::Value::from("nfc")]);
1562+
1563+
// An unknown transport leaves the list empty.
1564+
response.transport = None;
1565+
let model = response.to_idl_model(&request).unwrap();
1566+
assert!(model.response.transports.is_empty());
1567+
}
1568+
1569+
#[test]
1570+
fn test_response_to_idl_model_transports_from_get_info() {
1571+
// The authenticator's getInfo (0x09) transports are folded with the
1572+
// ceremony transport, as unique tokens in lexicographical order.
1573+
let mut response = create_test_response();
1574+
let request = create_test_request();
1575+
1576+
// Reported out of order with a duplicate; the ceremony transport (ble) folds in.
1577+
response.transport = Some(Transport::Ble);
1578+
response.authenticator_transports = Some(vec![
1579+
"usb".to_string(),
1580+
"nfc".to_string(),
1581+
"usb".to_string(),
1582+
]);
1583+
let model = response.to_idl_model(&request).unwrap();
1584+
assert_eq!(
1585+
model.response.transports,
1586+
vec!["ble".to_string(), "nfc".to_string(), "usb".to_string()]
1587+
);
1588+
1589+
// A ceremony transport already in the reported list is not duplicated.
1590+
response.transport = Some(Transport::Usb);
1591+
response.authenticator_transports = Some(vec!["usb".to_string(), "nfc".to_string()]);
1592+
let model = response.to_idl_model(&request).unwrap();
1593+
assert_eq!(
1594+
model.response.transports,
1595+
vec!["nfc".to_string(), "usb".to_string()]
1596+
);
1597+
1598+
// No reported transports leaves just the ceremony transport.
1599+
response.authenticator_transports = None;
1600+
let model = response.to_idl_model(&request).unwrap();
1601+
assert_eq!(model.response.transports, vec!["usb".to_string()]);
1602+
1603+
// Unknown tokens pass through, folded with the ceremony transport.
1604+
response.transport = Some(Transport::Ble);
1605+
response.authenticator_transports =
1606+
Some(vec!["smart-card".to_string(), "custom".to_string()]);
1607+
let model = response.to_idl_model(&request).unwrap();
1608+
assert_eq!(
1609+
model.response.transports,
1610+
vec![
1611+
"ble".to_string(),
1612+
"custom".to_string(),
1613+
"smart-card".to_string()
1614+
]
1615+
);
1616+
}
1617+
15061618
#[test]
15071619
fn test_response_emits_spki_for_es256() {
15081620
// The test fixture builds an ES256 P-256 credential, so getPublicKey()

libwebauthn/src/proto/ctap2/model.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::proto::ctap1::Ctap1Transport;
2+
use crate::Transport;
23
use crate::{
34
ops::webauthn::idl::create::PublicKeyCredentialUserEntity, pin::PinUvAuthProtocol,
45
webauthn::Error,
@@ -167,6 +168,17 @@ impl From<&Ctap1Transport> for Ctap2Transport {
167168
}
168169
}
169170

171+
impl From<Transport> for Ctap2Transport {
172+
fn from(transport: Transport) -> Ctap2Transport {
173+
match transport {
174+
Transport::Usb => Ctap2Transport::Usb,
175+
Transport::Ble => Ctap2Transport::Ble,
176+
Transport::Nfc => Ctap2Transport::Nfc,
177+
Transport::Hybrid => Ctap2Transport::Hybrid,
178+
}
179+
}
180+
}
181+
170182
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171183
pub struct Ctap2PublicKeyCredentialDescriptor {
172184
pub id: ByteBuf,

libwebauthn/src/proto/ctap2/model/make_credential.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,8 @@ impl Ctap2MakeCredentialResponse {
404404
enterprise_attestation: self.enterprise_attestation,
405405
large_blob_key: self.large_blob_key.map(|x| x.into_vec()),
406406
unsigned_extensions_output,
407+
transport: None,
408+
authenticator_transports: info.and_then(|i| i.transports.clone()),
407409
}
408410
}
409411
}

libwebauthn/src/transport/ble/channel.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::transport::channel::{
1515
use crate::transport::device::SupportedProtocols;
1616
use crate::transport::error::TransportError;
1717
use crate::webauthn::error::Error;
18+
use crate::Transport;
1819
use crate::UvUpdate;
1920

2021
use super::btleplug::manager::SupportedRevisions;
@@ -81,6 +82,10 @@ impl Display for BleChannel<'_> {
8182
impl<'a> Channel for BleChannel<'a> {
8283
type UxUpdate = UvUpdate;
8384

85+
fn transport(&self) -> Transport {
86+
Transport::Ble
87+
}
88+
8489
async fn supported_protocols(&self) -> Result<SupportedProtocols, Error> {
8590
Ok(self.revision.into())
8691
}

libwebauthn/src/transport/cable/channel.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::transport::{
1818
channel::ChannelStatus, device::SupportedProtocols, Channel, Ctap2AuthTokenStore,
1919
};
2020
use crate::webauthn::error::Error;
21+
use crate::Transport;
2122
use crate::UvUpdate;
2223

2324
use super::known_devices::CableKnownDevice;
@@ -125,6 +126,10 @@ impl From<UvUpdate> for CableUxUpdate {
125126
impl Channel for CableChannel {
126127
type UxUpdate = CableUxUpdate;
127128

129+
fn transport(&self) -> Transport {
130+
Transport::Hybrid
131+
}
132+
128133
async fn supported_protocols(&self) -> Result<SupportedProtocols, Error> {
129134
Ok(SupportedProtocols::fido2_only())
130135
}

libwebauthn/src/transport/channel.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::proto::{
1111
ctap2::cbor::{CborRequest, CborResponse},
1212
};
1313
use crate::webauthn::error::Error;
14+
use crate::Transport;
1415
use crate::UvUpdate;
1516

1617
use async_trait::async_trait;
@@ -64,6 +65,12 @@ pub trait Channel: Send + Sync + Display + Ctap2AuthTokenStore {
6465
async fn status(&self) -> ChannelStatus;
6566
async fn close(&mut self);
6667

68+
/// The transport this channel speaks over, used to populate the registration
69+
/// response `transports` member.
70+
fn transport(&self) -> Transport {
71+
Transport::Usb
72+
}
73+
6774
async fn apdu_send(&mut self, request: &ApduRequest, timeout: Duration) -> Result<(), Error>;
6875
async fn apdu_recv(&mut self, timeout: Duration) -> Result<ApduResponse, Error>;
6976

libwebauthn/src/transport/hid/channel.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::transport::hid::framing::{
3434
HidCommand, HidMessage, HidMessageParser, HidMessageParserState,
3535
};
3636
use crate::webauthn::error::{Error, PlatformError};
37+
use crate::Transport;
3738
use crate::UvUpdate;
3839

3940
use super::device::get_hidapi;
@@ -505,6 +506,10 @@ impl Display for HidChannel<'_> {
505506
impl Channel for HidChannel<'_> {
506507
type UxUpdate = UvUpdate;
507508

509+
fn transport(&self) -> Transport {
510+
Transport::Usb
511+
}
512+
508513
async fn supported_protocols(&self) -> Result<SupportedProtocols, Error> {
509514
let cbor_supported = self.init.caps.contains(Caps::CBOR);
510515
let apdu_supported = !self.init.caps.contains(Caps::NO_MSG);

libwebauthn/src/transport/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ pub mod hid;
2323
pub mod mock;
2424
#[cfg(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc"))]
2525
pub mod nfc;
26+
// No NFC backend compiled: a stub so callers need not gate on the feature.
27+
#[cfg(not(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc")))]
28+
pub mod nfc {
29+
pub fn is_nfc_available() -> bool {
30+
false
31+
}
32+
}
2633

2734
mod channel;
2835
#[allow(clippy::module_inception)]

libwebauthn/src/transport/nfc/channel.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::transport::channel::{
2121
use crate::transport::device::SupportedProtocols;
2222
use crate::transport::error::TransportError;
2323
use crate::webauthn::Error;
24+
use crate::Transport;
2425
use crate::UvUpdate;
2526

2627
use super::commands::{command_ctap_msg, command_get_response};
@@ -259,6 +260,10 @@ where
259260
{
260261
type UxUpdate = UvUpdate;
261262

263+
fn transport(&self) -> Transport {
264+
Transport::Nfc
265+
}
266+
262267
async fn supported_protocols(&self) -> Result<SupportedProtocols, Error> {
263268
Ok(self.supported)
264269
}

0 commit comments

Comments
 (0)