Skip to content

Commit b497ec4

Browse files
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.
1 parent d593d6b commit b497ec4

16 files changed

Lines changed: 170 additions & 29 deletions

File tree

libwebauthn/examples/ceremony/webauthn_ble.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
7575
.unwrap();
7676
println!("WebAuthn MakeCredential response: {:?}", response);
7777

78-
match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) {
78+
match response.to_json_string(
79+
&make_credentials_request,
80+
channel.transport(),
81+
JsonFormat::Prettified,
82+
) {
7983
Ok(response_json) => {
8084
println!(
8185
"WebAuthn MakeCredential response (JSON):\n{}",
@@ -113,7 +117,11 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
113117
println!("WebAuthn GetAssertion response: {:?}", response);
114118

115119
for assertion in &response.assertions {
116-
match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) {
120+
match assertion.to_json_string(
121+
&get_assertion,
122+
channel.transport(),
123+
JsonFormat::Prettified,
124+
) {
117125
Ok(assertion_json) => {
118126
println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json);
119127
}

libwebauthn/examples/ceremony/webauthn_cable.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
9292

9393
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
9494
let response_json = response
95-
.to_json_string(&request, JsonFormat::Prettified)
95+
.to_json_string(&request, channel.transport(), JsonFormat::Prettified)
9696
.expect("Failed to serialize MakeCredential response");
9797
println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
9898

libwebauthn/examples/ceremony/webauthn_cable_wss.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
110110

111111
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
112112
let response_json = response
113-
.to_json_string(&request, JsonFormat::Prettified)
113+
.to_json_string(&request, channel.transport(), JsonFormat::Prettified)
114114
.expect("Failed to serialize MakeCredential response");
115115
println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
116116
}
@@ -182,7 +182,7 @@ async fn run_get_assertion(
182182
let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap();
183183
for assertion in &response.assertions {
184184
let assertion_json = assertion
185-
.to_json_string(&request, JsonFormat::Prettified)
185+
.to_json_string(&request, channel.transport(), JsonFormat::Prettified)
186186
.expect("Failed to serialize GetAssertion response");
187187
println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}");
188188
}

libwebauthn/examples/ceremony/webauthn_hid.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
7979
.unwrap();
8080
println!("WebAuthn MakeCredential response: {:?}", response);
8181

82-
match response.to_json_string(&make_credentials_request, JsonFormat::Prettified) {
82+
match response.to_json_string(
83+
&make_credentials_request,
84+
channel.transport(),
85+
JsonFormat::Prettified,
86+
) {
8387
Ok(response_json) => {
8488
println!(
8589
"WebAuthn MakeCredential response (JSON):\n{}",
@@ -117,7 +121,11 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
117121
println!("WebAuthn GetAssertion response: {:?}", response);
118122

119123
for assertion in &response.assertions {
120-
match assertion.to_json_string(&get_assertion, JsonFormat::Prettified) {
124+
match assertion.to_json_string(
125+
&get_assertion,
126+
channel.transport(),
127+
JsonFormat::Prettified,
128+
) {
121129
Ok(assertion_json) => {
122130
println!("WebAuthn GetAssertion response (JSON):\n{}", assertion_json);
123131
}

libwebauthn/examples/ceremony/webauthn_nfc.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
7777
let response =
7878
retry_user_errors!(channel.webauthn_make_credential(&make_credentials_request)).unwrap();
7979
let response_json = response
80-
.to_json_string(&make_credentials_request, JsonFormat::Prettified)
80+
.to_json_string(
81+
&make_credentials_request,
82+
channel.transport(),
83+
JsonFormat::Prettified,
84+
)
8185
.expect("Failed to serialize MakeCredential response");
8286
println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
8387

@@ -100,7 +104,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
100104
let response = retry_user_errors!(channel.webauthn_get_assertion(&get_assertion)).unwrap();
101105
for assertion in &response.assertions {
102106
let assertion_json = assertion
103-
.to_json_string(&get_assertion, JsonFormat::Prettified)
107+
.to_json_string(&get_assertion, channel.transport(), JsonFormat::Prettified)
104108
.expect("Failed to serialize GetAssertion response");
105109
println!("WebAuthn GetAssertion response (JSON):\n{assertion_json}");
106110
}

libwebauthn/examples/features/webauthn_related_origins_hid.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ pub async fn main() -> Result<(), Box<dyn Error>> {
7676

7777
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
7878
let response_json = response
79-
.to_json_string(&request, JsonFormat::Prettified)
79+
.to_json_string(&request, channel.transport(), JsonFormat::Prettified)
8080
.expect("Failed to serialize MakeCredential response");
8181
println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
8282
}

libwebauthn/src/lib.rs

Lines changed: 34 additions & 2 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,36 @@ impl PartialEq for PinNotSetUpdate {
213215
}
214216
}
215217

218+
/// Transports compiled into this build. Hybrid/caBLE is always included. Using it
219+
/// at runtime still needs a BLE adapter (see `transport::cable::is_available`).
220+
/// NFC appears only when an `nfc-backend-*` feature is enabled.
216221
pub fn available_transports() -> Vec<Transport> {
217-
vec![Transport::Usb, Transport::Ble]
222+
[
223+
Transport::Usb,
224+
Transport::Ble,
225+
Transport::Hybrid,
226+
#[cfg(any(feature = "nfc-backend-pcsc", feature = "nfc-backend-libnfc"))]
227+
Transport::Nfc,
228+
]
229+
.into_iter()
230+
.collect()
231+
}
232+
233+
#[cfg(test)]
234+
mod tests {
235+
use super::*;
236+
237+
#[test]
238+
fn available_transports_reports_compiled_in() {
239+
let transports = available_transports();
240+
assert!(transports.contains(&Transport::Usb));
241+
assert!(transports.contains(&Transport::Ble));
242+
assert!(transports.contains(&Transport::Hybrid));
243+
244+
let nfc_compiled = cfg!(any(
245+
feature = "nfc-backend-pcsc",
246+
feature = "nfc-backend-libnfc"
247+
));
248+
assert_eq!(transports.contains(&Transport::Nfc), nfc_compiled);
249+
}
218250
}

libwebauthn/src/ops/webauthn/get_assertion.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ impl WebAuthnIDLResponse for Assertion {
489489
fn to_idl_model(
490490
&self,
491491
request: &Self::Context,
492+
_transport: Option<crate::Transport>,
492493
) -> Result<Self::IdlModel, ResponseSerializationError> {
493494
// Get credential ID - either from credential_id field or from authenticator_data
494495
let credential_id_bytes = self
@@ -1306,7 +1307,7 @@ mod tests {
13061307

13071308
let assertion = create_test_assertion();
13081309
let request = create_test_request();
1309-
let json = assertion.to_json_string(&request, JsonFormat::default());
1310+
let json = assertion.to_json_string(&request, None, JsonFormat::default());
13101311
assert!(json.is_ok());
13111312

13121313
let json_str = json.unwrap();
@@ -1332,7 +1333,7 @@ mod tests {
13321333
fn test_assertion_to_idl_model() {
13331334
let assertion = create_test_assertion();
13341335
let request = create_test_request();
1335-
let model = assertion.to_idl_model(&request).unwrap();
1336+
let model = assertion.to_idl_model(&request, None).unwrap();
13361337

13371338
// Verify the credential ID
13381339
assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]);
@@ -1354,7 +1355,7 @@ mod tests {
13541355
));
13551356

13561357
let request = create_test_request();
1357-
let model = assertion.to_idl_model(&request).unwrap();
1358+
let model = assertion.to_idl_model(&request, None).unwrap();
13581359

13591360
// Verify user handle is present
13601361
assert!(model.response.user_handle.is_some());
@@ -1381,7 +1382,7 @@ mod tests {
13811382
});
13821383

13831384
let request = create_test_request();
1384-
let model = assertion.to_idl_model(&request).unwrap();
1385+
let model = assertion.to_idl_model(&request, None).unwrap();
13851386

13861387
// Verify extension outputs - PRF should be set with correct values
13871388
let prf = model.client_extension_results.prf.as_ref().unwrap();
@@ -1403,7 +1404,7 @@ mod tests {
14031404
});
14041405

14051406
let request = create_test_request();
1406-
let model = assertion.to_idl_model(&request).unwrap();
1407+
let model = assertion.to_idl_model(&request, None).unwrap();
14071408
assert_eq!(model.client_extension_results.appid, Some(true));
14081409

14091410
// The output should also round-trip through the JSON wire format.
@@ -1427,7 +1428,7 @@ mod tests {
14271428
});
14281429

14291430
let request = create_test_request();
1430-
let model = assertion.to_idl_model(&request).unwrap();
1431+
let model = assertion.to_idl_model(&request, None).unwrap();
14311432
assert_eq!(model.client_extension_results.appid, None);
14321433

14331434
let json = serde_json::to_value(&model.client_extension_results).unwrap();

libwebauthn/src/ops/webauthn/idl/response.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,28 +55,33 @@ pub trait WebAuthnIDLResponse: Sized {
5555
/// Context required for serialization (e.g., client data JSON).
5656
type Context;
5757

58-
/// Converts this response to a JSON-serializable IDL model.
58+
/// Converts this response to a JSON-serializable IDL model. `transport` is the
59+
/// transport the ceremony ran over, used to populate the registration
60+
/// `transports` member. Pass `None` when it is unknown.
5961
fn to_idl_model(
6062
&self,
6163
ctx: &Self::Context,
64+
transport: Option<crate::Transport>,
6265
) -> Result<Self::IdlModel, ResponseSerializationError>;
6366

6467
/// Serializes this response to a `serde_json::Value`.
6568
fn to_json_value(
6669
&self,
6770
ctx: &Self::Context,
71+
transport: Option<crate::Transport>,
6872
) -> Result<serde_json::Value, ResponseSerializationError> {
69-
let model = self.to_idl_model(ctx)?;
73+
let model = self.to_idl_model(ctx, transport)?;
7074
Ok(serde_json::to_value(&model)?)
7175
}
7276

7377
/// Serializes this response to a JSON string.
7478
fn to_json_string(
7579
&self,
7680
ctx: &Self::Context,
81+
transport: Option<crate::Transport>,
7782
format: JsonFormat,
7883
) -> Result<String, ResponseSerializationError> {
79-
let value = self.to_json_value(ctx)?;
84+
let value = self.to_json_value(ctx, transport)?;
8085
match format {
8186
JsonFormat::Minified => Ok(serde_json::to_string(&value)?),
8287
JsonFormat::Prettified => Ok(serde_json::to_string_pretty(&value)?),

libwebauthn/src/ops/webauthn/make_credential.rs

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ 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
},
@@ -60,13 +60,29 @@ struct AttestationObject<'a> {
6060
attestation_statement: &'a Ctap2AttestationStatement,
6161
}
6262

63+
/// Maps the active transport to AuthenticatorTransport tokens for the registration
64+
/// `transports` member. The list is deduplicated and lexicographically sorted per
65+
/// WebAuthn L3 §5.2.1.1, and is empty when the transport is unknown.
66+
fn registration_transports(transport: Option<crate::Transport>) -> Vec<String> {
67+
let mut tokens: Vec<String> = transport
68+
.into_iter()
69+
.map(Ctap2Transport::from)
70+
.filter_map(|t| serde_json::to_value(t).ok())
71+
.filter_map(|v| v.as_str().map(str::to_owned))
72+
.collect();
73+
tokens.sort();
74+
tokens.dedup();
75+
tokens
76+
}
77+
6378
impl WebAuthnIDLResponse for MakeCredentialResponse {
6479
type IdlModel = RegistrationResponseJSON;
6580
type Context = MakeCredentialRequest;
6681

6782
fn to_idl_model(
6883
&self,
6984
request: &Self::Context,
85+
transport: Option<crate::Transport>,
7086
) -> Result<Self::IdlModel, ResponseSerializationError> {
7187
// The AT flag MUST be set on makeCredential responses per CTAP 2.2 §6.1.
7288
let attested = self
@@ -102,8 +118,7 @@ impl WebAuthnIDLResponse for MakeCredentialResponse {
102118
// Build attestation object (CBOR map with authData, fmt, attStmt)
103119
let attestation_object_bytes = self.build_attestation_object(&authenticator_data_bytes)?;
104120

105-
// Get transports (we don't have direct access, so return empty for now)
106-
let transports = Vec::new();
121+
let transports = registration_transports(transport);
107122

108123
// Build client extension results
109124
let client_extension_results = self.build_client_extension_results();
@@ -1434,7 +1449,7 @@ mod tests {
14341449

14351450
let response = create_test_response();
14361451
let request = create_test_request();
1437-
let json = response.to_json_string(&request, JsonFormat::default());
1452+
let json = response.to_json_string(&request, None, JsonFormat::default());
14381453
assert!(json.is_ok());
14391454

14401455
let json_str = json.unwrap();
@@ -1489,7 +1504,7 @@ mod tests {
14891504
fn test_response_to_idl_model() {
14901505
let response = create_test_response();
14911506
let request = create_test_request();
1492-
let model = response.to_idl_model(&request).unwrap();
1507+
let model = response.to_idl_model(&request, None).unwrap();
14931508

14941509
// Verify the credential ID
14951510
assert_eq!(model.raw_id.0, vec![0x01, 0x02, 0x03, 0x04]);
@@ -1503,6 +1518,41 @@ mod tests {
15031518
assert!(model.response.transports.is_empty());
15041519
}
15051520

1521+
#[test]
1522+
fn test_response_to_idl_model_populates_transports() {
1523+
// WebAuthn L3 §5.2.1.1: the registration `transports` member reports the
1524+
// transport the credential was created over, as AuthenticatorTransport tokens.
1525+
// Both the FIDO2 and U2F-downgrade paths converge on this serialization.
1526+
let response = create_test_response();
1527+
let request = create_test_request();
1528+
1529+
for (transport, token) in [
1530+
(crate::Transport::Usb, "usb"),
1531+
(crate::Transport::Ble, "ble"),
1532+
(crate::Transport::Nfc, "nfc"),
1533+
(crate::Transport::Hybrid, "hybrid"),
1534+
] {
1535+
let model = response.to_idl_model(&request, Some(transport)).unwrap();
1536+
assert_eq!(model.response.transports, vec![token.to_string()]);
1537+
}
1538+
1539+
// The token reaches the JSON wire format too.
1540+
let json = response
1541+
.to_json_string(
1542+
&request,
1543+
Some(crate::Transport::Nfc),
1544+
crate::ops::webauthn::idl::response::JsonFormat::default(),
1545+
)
1546+
.unwrap();
1547+
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1548+
let transports = parsed["response"]["transports"].as_array().unwrap();
1549+
assert_eq!(transports, &vec![serde_json::Value::from("nfc")]);
1550+
1551+
// An unknown transport leaves the list empty.
1552+
let model = response.to_idl_model(&request, None).unwrap();
1553+
assert!(model.response.transports.is_empty());
1554+
}
1555+
15061556
#[test]
15071557
fn test_response_emits_spki_for_es256() {
15081558
// The test fixture builds an ES256 P-256 credential, so getPublicKey()
@@ -1512,7 +1562,7 @@ mod tests {
15121562
// by the secp256r1 OID and the uncompressed point.
15131563
let response = create_test_response();
15141564
let request = create_test_request();
1515-
let model = response.to_idl_model(&request).unwrap();
1565+
let model = response.to_idl_model(&request, None).unwrap();
15161566

15171567
let public_key_bytes = model
15181568
.response
@@ -1536,7 +1586,7 @@ mod tests {
15361586
fn test_response_attestation_object_format() {
15371587
let response = create_test_response();
15381588
let request = create_test_request();
1539-
let model = response.to_idl_model(&request).unwrap();
1589+
let model = response.to_idl_model(&request, None).unwrap();
15401590

15411591
// Decode the attestation object
15421592
let attestation_bytes = model.response.attestation_object.0;
@@ -1581,7 +1631,7 @@ mod tests {
15811631
};
15821632

15831633
let request = create_test_request();
1584-
let model = response.to_idl_model(&request).unwrap();
1634+
let model = response.to_idl_model(&request, None).unwrap();
15851635

15861636
// Verify cred_props extension
15871637
let cred_props = model.client_extension_results.cred_props.as_ref().unwrap();

0 commit comments

Comments
 (0)