@@ -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
3940use 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+
6383impl 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()
0 commit comments