Skip to content

feat(cable): hybrid transport over a direct BLE L2CAP channel#216

Draft
AlfioEmanueleFresta wants to merge 17 commits into
masterfrom
feat/pxp-ble
Draft

feat(cable): hybrid transport over a direct BLE L2CAP channel#216
AlfioEmanueleFresta wants to merge 17 commits into
masterfrom
feat/pxp-ble

Conversation

@AlfioEmanueleFresta
Copy link
Copy Markdown
Member

@AlfioEmanueleFresta AlfioEmanueleFresta commented May 14, 2026

Adds support for the CTAP 2.3 hybrid transport over a direct BLE L2CAP channel, with no tunnel server.

Commits 1-2 refactor the Noise handshake and encrypted framing onto a transport-agnostic data-channel abstraction. The rest add advertisement-suffix parsing, the QR key 6 transport list, a bluer-backed L2CAP data channel (l2cap feature only, no D-Bus), and L2CAP-or-WebSocket selection in connection_stage.

Scope is QR-initiated only. The draft has no way to negotiate a BLE channel for state-assisted transactions, so those stay on WebSocket.

Validated on Android 16 (Pixel 8 with Google Play Services). Checked for regressions on iOS.

@iinuwa
Copy link
Copy Markdown
Member

iinuwa commented May 14, 2026

Got an error:

Created QR code, awaiting for advertisement.
█████████████████████████████████████████████
█████████████████████████████████████████████
████ ▄▄▄▄▄ █▀ ▀▀▀ █ █▀▀▄▀▀▄█▄▄▄ ▀█ ▄▄▄▄▄ ████
████ █   █ ███▀█ ▄▀ ██ ▀▄▄  ██ ▀ █ █   █ ████
████ █▄▄▄█ █▄▀▀ ▄  █▄▀█▀ ▀ █ █ ▀██ █▄▄▄█ ████
████▄▄▄▄▄▄▄█▄█ █▄▀▄▀ ▀▄█ ▀▄█ █ █▄█▄▄▄▄▄▄▄████
████ █▄▀▄▀▄▀▀  ▄▄██▀ ▀▄  ▀▀▄█ ▀ ▄█▀▀ █▀ █████
████▄▄  ▄▀▄  ▄▄ ██▀▄ ▄▄▀▄██▀  ▄▄█▄▄ ▄▀  █████
█████▀  █▄▄▄▄▄▄▄ ▄▀ ▄ █ ▄█▄▄ ▄▀█ ▄█   ▄ ▄████
████▄▀▀▀  ▄▀██▄█▀ ▄▀▄▀▄██▄  ▄ █▄▄▀ ██▄▀▀█████
████ █ ▄█▀▄▀▄   ▄██ █▄█▄▄▄█▀▄▄█▀█ ▄ █ █▄▄████
████▀ █▀█▄▄▄█▄▀ ▄▀ ▀█▄▀▀█▄▄▄ █▀█▄█▄ █▄ ▄ ████
████▀▀  ▀▀▄ █ ▀▄▀▀█▀█████▄██▀▀█▄▀ ▄█▄▀▀▀▀████
████  █ ▄▀▄ ▀█▄ ▀▄▄▀▄█▄▀ ▀▄█ █▀█▀▀█  ▄  ▄████
████▄█▀▄▀ ▄ ▀█▀ ▀ ▄▀ ▀▀ █▀   █▀▀▀▄▀▀ ▀   ████
████▄▀▀▀▄█▄▄▀▄▄ ▀█▄▄ ▀█  ███ ▀▀▄ ▀   █▄█ ████
████▄███▄█▄█▀▄█▀   ▄▄▄▀ █▀▄▀▄▄▄  ▄▄▄ ▄▄█▄████
████ ▄▄▄▄▄ ██▀▀▄▄▀▀  █ █ █  ▀▄▀  █▄█ ▀ ▀▀████
████ █   █ █▄▀ ▄█  ▀▄▀▄▄ ▄▀▀▄█▀  ▄ ▄ ▄▀ ▄████
████ █▄▄▄█ █▄▀█▄█▄▄▀▄▀█▀▀▄ █ █ ▄   ▀ █▀▀ ████
████▄▄▄▄▄▄▄█▄██▄▄█▄█▄▄▄█▄▄████▄█▄███▄▄██▄████
█████████████████████████████████████████████
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Channel established CableChannel { handle_connection: JoinHandle { id: Id(6) }, cbor_sender: Sender { chan: Tx { inner: Chan { tx: Tx { block_tail: 0x55f0032e2eb0, tail_position: 0 }, semaphore: Semaphore { semaphore: Semaphore { permits: 16 }, bound: 16 }, rx_waker: AtomicWaker, tx_count: 1, rx_fields: "..." } } }, cbor_receiver: Receiver { chan: Rx { inner: Chan { tx: Tx { block_tail: 0x55f0032e58b0, tail_position: 0 }, semaphore: Semaphore { semaphore: Semaphore { permits: 16 }, bound: 16 }, rx_waker: AtomicWaker, tx_count: 1, rx_fields: "..." } } }, ux_update_sender: broadcast::Sender, connection_state_receiver: Receiver { shared: Shared { value: RwLock(PhantomData<std::sync::poison::rwlock::RwLock<libwebauthn::transport::cable::channel::ConnectionState>>, RwLock { data: Connecting }), version: Version(0), is_closed: false, ref_count_rx: 1 }, version: Version(0) } }
TRACE webauthn_make_credential{dev=CableChannel}: libwebauthn::webauthn: WebAuthn MakeCredential request op=MakeCredentialRequest { challenge: [49, 50, 51, 52, 53, 54, 55, 56, 49, 50, 51, 52, 53, 54, 55, 56, 49, 50, 51, 52, 53, 54, 55, 56, 49, 50, 51, 52, 53, 54, 55, 56], origin: "https://example.org", top_origin: None, relying_party: Ctap2PublicKeyCredentialRpEntity { id: "example.org", name: Some("Example Relying Party") }, user: Ctap2PublicKeyCredentialUserEntity { id: [49, 50, 51, 52, 53, 54, 55, 56, 49, 50, 51, 52, 53, 54, 55, 56, 49, 50, 51, 52, 53, 54, 55, 56, 49, 50, 51, 52, 53, 54, 55, 56], name: Some("Mario Rossi"), display_name: Some("Mario Rossi") }, resident_key: Some(Discouraged), user_verification: Preferred, algorithms: [Ctap2CredentialType { algorithm: ES256, public_key_type: PublicKey }], exclude: None, extensions: None, timeout: 120s }
DEBUG webauthn_make_credential{dev=CableChannel}:negotiate_protocol: libwebauthn::webauthn: Selected protocol: FIDO2
DEBUG connection:proximity_check_stage: libwebauthn::transport::cable::connection_stages: Starting proximity check stage
TRACE connection:proximity_check_stage:send_update{update=CableUpdate(ProximityCheck)}: libwebauthn::transport::cable::connection_stages: Sending UX update
Proximity check in progress...
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Ignoring periperal as it doesn't have service data for desired UUID id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_41_54_2C_8D_42_97\0") }) service_data={0000fc8f-0000-1000-8000-00805f9b34fb: [0]}
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Ignoring periperal as it doesn't have service data for desired UUID id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_3B_E5_53_2B_DD_33\0") }) service_data={0000fcf1-0000-1000-8000-00805f9b34fb: [4, 245, 235, 134, 65, 61, 41, 203, 27, 47, 151, 13, 237, 10, 228, 188, 57, 192, 64, 158, 104]}
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Ignoring periperal as it doesn't have service data for desired UUID id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_3B_E5_53_2B_DD_33\0") }) service_data={0000fcf1-0000-1000-8000-00805f9b34fb: [4, 245, 235, 134, 65, 61, 41, 203, 27, 47, 151, 13, 237, 10, 228, 188, 57, 192, 64, 158, 104]}
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Ignoring periperal as it doesn't have service data for desired UUID id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_41_54_2C_8D_42_97\0") }) service_data={0000fc8f-0000-1000-8000-00805f9b34fb: [0]}
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Ignoring periperal as it doesn't have service data for desired UUID id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_C9_4B_2F_3D_6D_63\0") }) service_data={0000feaf-0000-1000-8000-00805f9b34fb: [16, 1, 0, 2, 0, 225, 8, 0, 94, 177, 67, 100, 0, 102, 22, 100, 1]}
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Found service data id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_4C_DD_9C_05_D3_AB\0") }) service_data=[95, 67, 151, 68, 90, 243, 47, 188, 67, 51, 213, 183, 28, 38, 201, 32, 19, 78, 71, 48, 161, 1, 24, 130]
DEBUG connection:proximity_check_stage:await_advertisement: libwebauthn::transport::ble::btleplug::manager: Found service data for peripheral id=PeripheralId(DeviceId { object_path: Path("/org/bluez/hci0/dev_4C_DD_9C_05_D3_AB\0") }) service_data=[95, 67, 151, 68, 90, 243, 47, 188, 67, 51, 213, 183, 28, 38, 201, 32, 19, 78, 71, 48, 161, 1, 24, 130]
DEBUG connection:proximity_check_stage:await_advertisement: libwebauthn::transport::cable::advertisement: Found device with service data peripheral=Peripheral { session: BluetoothSession, device: DeviceId { object_path: Path("/org/bluez/hci0/dev_4C_DD_9C_05_D3_AB\0") }, mac_address: 4C:DD:9C:05:D3:AB, services: Mutex { data: {}, poisoned: false, .. } } data=[95, 67, 151, 68, 90, 243, 47, 188, 67, 51, 213, 183, 28, 38, 201, 32, 19, 78, 71, 48, 161, 1, 24, 130]
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::cable::advertisement: device=FidoDevice { peripheral: Peripheral { session: BluetoothSession, device: DeviceId { object_path: Path("/org/bluez/hci0/dev_4C_DD_9C_05_D3_AB\0") }, mac_address: 4C:DD:9C:05:D3:AB, services: Mutex { data: {}, poisoned: false, .. } }, properties: PeripheralProperties { address: 4C:DD:9C:05:D3:AB, address_type: Some(Random), local_name: None, tx_power_level: None, rssi: Some(-44), manufacturer_data: {}, service_data: {0000fff9-0000-1000-8000-00805f9b34fb: [95, 67, 151, 68, 90, 243, 47, 188, 67, 51, 213, 183, 28, 38, 201, 32, 19, 78, 71, 48, 161, 1, 24, 130]}, services: [0000fff9-0000-1000-8000-00805f9b34fb], class: None } } data=[95, 67, 151, 68, 90, 243, 47, 188, 67, 51, 213, 183, 28, 38, 201, 32, 19, 78, 71, 48, 161, 1, 24, 130] eid_key=[252, 25, 70, 156, 7, 85, 151, 254, 224, 241, 108, 252, 0, 123, 128, 56, 162, 214, 131, 149, 111, 158, 7, 166, 181, 38, 222, 116, 240, 208, 217, 14, 48, 68, 165, 201, 2, 33, 54, 187, 138, 81, 173, 139, 226, 115, 164, 144, 233, 1, 43, 46, 209, 244, 80, 166, 15, 128, 112, 157, 194, 249, 175, 172]
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::cable::advertisement: decrypted=[0, 126, 177, 141, 184, 30, 199, 57, 62, 237, 157, 124, 145, 38, 122, 114]
TRACE connection:proximity_check_stage:await_advertisement: libwebauthn::transport::cable::advertisement: Parsed advertisement suffix suffix=AdvertisementSuffix { channels: {1: Integer(130)} }
DEBUG connection:proximity_check_stage:await_advertisement: libwebauthn::transport::cable::advertisement: Successfully decrypted advertisement from device device=FidoDevice { peripheral: Peripheral { session: BluetoothSession, device: DeviceId { object_path: Path("/org/bluez/hci0/dev_4C_DD_9C_05_D3_AB\0") }, mac_address: 4C:DD:9C:05:D3:AB, services: Mutex { data: {}, poisoned: false, .. } }, properties: PeripheralProperties { address: 4C:DD:9C:05:D3:AB, address_type: Some(Random), local_name: None, tx_power_level: None, rssi: Some(-44), manufacturer_data: {}, service_data: {0000fff9-0000-1000-8000-00805f9b34fb: [95, 67, 151, 68, 90, 243, 47, 188, 67, 51, 213, 183, 28, 38, 201, 32, 19, 78, 71, 48, 161, 1, 24, 130]}, services: [0000fff9-0000-1000-8000-00805f9b34fb], class: None } } decrypted=[0, 126, 177, 141, 184, 30, 199, 57, 62, 237, 157, 124, 145, 38, 122, 114]
DEBUG connection:proximity_check_stage: libwebauthn::transport::cable::connection_stages: Proximity check completed successfully
DEBUG connection:connection_stage: libwebauthn::transport::cable::connection_stages: Starting connection stage input.tunnel_domain="cable.hl4dj2chzxo2c.info"
TRACE connection:connection_stage:send_update{update=CableUpdate(Connecting)}: libwebauthn::transport::cable::connection_stages: Sending UX update
ERROR connection:connection_stage: libwebauthn::transport::cable::l2cap: Failed to set L2CAP security level e=Os { code: 22, kind: InvalidInput, message: "Invalid argument" }
 WARN connection:connection_stage: libwebauthn::transport::cable::connection_stages: BLE L2CAP connection failed, falling back to WebSocket tunnel e=IoError(InvalidInput)
DEBUG connection:connection_stage: libwebauthn::transport::cable::tunnel: Connecting to tunnel server connect_url="wss://cable.hl4dj2chzxo2c.info/cable/connect/7c9126/26c089476234059197ab191ab85400ed"
TRACE connection:connection_stage: libwebauthn::transport::cable::tunnel: request=Request { method: GET, uri: wss://cable.hl4dj2chzxo2c.info/cable/connect/7c9126/26c089476234059197ab191ab85400ed, version: HTTP/1.1, headers: {"host": "cable.hl4dj2chzxo2c.info", "connection": "Upgrade", "upgrade": "websocket", "sec-websocket-version": "13", "sec-websocket-key": "Z1+GcIhFz+NyN4ydG1kCOg==", "sec-websocket-protocol": "fido.cable"}, body: () }
Connecting to the device...
ERROR connection:connection_stage: libwebauthn::transport::cable::tunnel: Failed to connect to tunnel server e=Io(Custom { kind: Uncategorized, error: "failed to lookup address information: Name or service not known" })
ERROR connection:connection_stage: libwebauthn::transport::cable::connection_stages: error=connection failed
ERROR connection: libwebauthn::transport::cable::qr_code_device: error=Transport error: connection failed
TRACE send_update{update=CableUpdate(Error(ConnectionFailed))}: libwebauthn::transport::cable::connection_stages: Sending UX update
Error during connection: connection failed

thread 'main' (45529) panicked at libwebauthn/examples/ceremony/webauthn_cable.rs:98:87:
called `Result::unwrap()` on an `Err` value: Transport(ConnectionFailed)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

@AlfioEmanueleFresta AlfioEmanueleFresta changed the title feat(cable): PXP (CTAP 2.3 hybrid) over direct BLE L2CAP feat(cable): hybrid transport over a direct BLE L2CAP channel May 14, 2026
Adds a message-oriented duplex channel trait so the Noise handshake and
encrypted CTAP framing can run over any transport, plus a WebSocket
implementation. Unused until the protocol layer is moved onto it.
…ction

Lifts the Noise handshake, CableTunnelMessage framing, padding, the
connection loop and post-handshake/update parsing out of tunnel.rs into
a new protocol.rs, generic over a CableDataChannel instead of a
WebSocketStream. tunnel.rs keeps the WebSocket connect and tunnel-domain
decoding. connection_stages.rs carries a Box<dyn CableDataChannel>.

caBLE over WebSocket behaves identically; the abstraction lets a future
data channel (BLE L2CAP) plug in.
Accept BLE service data of length >= 20: trial-decrypt only the first
20 bytes, then parse any remainder as the CTAP 2.3 PXP advertisement
suffix, a CBOR map of transport_channel_identifier to channel_extra.

AdvertisementSuffix exposes ble_psm() for the BLE channel (id 1).
Unknown channel ids and out-of-range PSMs are ignored; a malformed
suffix is logged and treated as absent without failing the advert.

Both touched modules gain a module-scoped deny(indexing_slicing) and
move to checked slice access.
…list

The CTAP 2.3 hybrid draft assigns QR code key 6 to the array of data transfer
channels the client supports (0 = WebSocket, 1 = BLE). The previous code
used key 6 for a non-standard supports_non_discoverable_mc flag, which is
not in the spec and is removed here. The list is set to [WebSocket] for
now; BLE is added once the L2CAP channel is wired in.
Adds bluer (l2cap feature only, no D-Bus) and an L2capDataChannel
implementing CableDataChannel over an insecure L2CAP CoC for PXP.
CRLF message framing with a cancel-safe read buffer. Not yet wired
into the connection flow, so the type is dead_code for now.
connection_stage now inspects the decrypted advertisement suffix: if it
carries a BLE L2CAP PSM, it opens an L2capDataChannel to the peripheral
discovered during the proximity check, falling back to the WebSocket
tunnel if that fails. The QR code advertises both transports. Known
devices stay on WebSocket, as state-assisted has no BLE channel
negotiation in the CTAP 2.3 hybrid draft.
Adds a README feature line for the direct BLE L2CAP data channel, and
updates the cable example wording since the channel is no longer always
a tunnel. The example exercises CTAP 2.3 hybrid BLE unchanged: the QR code
advertises BLE and connection_stage selects L2CAP when offered.
The kernel rejects BT_SECURITY level < LOW on L2CAP CoC sockets with
-EINVAL (l2cap_sock_setsockopt), so set_security(Sdp) was failing the
whole connect path. The default sec_level on a fresh socket is already
BT_SECURITY_LOW, which on an LE link does not trigger pairing or
encryption: the 'insecure' CoC the spec asks for.

Switching to bluer::l2cap::Stream::connect also drops the redundant
explicit bind, which it does internally.
bluer's poll_write silently caps writes to 16 bytes while BT_SNDMTU is
not yet available, which happens for a brief window after connect()
returns and before the LE Credit Based Connection Response is processed
by the kernel. The ~80-byte Noise handshake then goes out as 5+ SDUs,
which some peers don't reassemble before timing out.

Poll send_mtu() (up to 2s, 50ms cadence) until the kernel has the peer's
MTU, so the handshake is sent in a single SDU.
In caBLE v2 key 6 was a 'supports_non_discoverable_mc' boolean. The PXP
draft repurposes it as the supported-transports array. Legacy clients
(Google Play services Fido 26.18.x, observed via 'HybridAuthenticate-
ChimeraActivity ... Expected a jcsv value, but got jcsw' on QR parse)
hard-reject a CBOR array where they expect a bool, and crash the scan.

Make the field Option<Vec<...>> and default to None; CTAP 2.3 hybrid-aware peers
that need the new array semantics can opt in via the public field once
they ship.
Per the caBLE v2 convention (cited by kanidm): Chrome omits this field
when false; presence implies v2.1, absence implies v2.0. Emitting
'state_assisted = false' was claiming v2.1 with no state-assisted
support, which is an unusual combination and the next plausible trigger
for legacy GMS Fido's strict QR parser to reject.
CableQrCodeDevice::new_persistent and new_transient now take a
CableTransports describing which transport channels the QR advertises:
websocket_only (caBLE v2 legacy), ble_only, or websocket_and_ble.

The set is a thin BTreeSet wrapper. Internally we collapse
websocket_only to no key 6, since legacy parsers hard-reject a CBOR
array at key 6 (where caBLE v2 expected a supports_non_discoverable_mc
boolean). Anything that includes BLE opts in to the CTAP 2.3 hybrid
transport-channel negotiation.
…info

The state-assisted second leg of the cable example panicked with 'No
known devices found' against any peer that didn't send linking info in
its post-handshake message (GMS Fido for one). Detect the empty
known-devices list and show a new QR for GetAssertion instead, so the
example demonstrates a working two-ceremony flow regardless of the
peer's state-assisted support.
The 'cable' name is reserved for an upcoming example that exercises
both transports.
Transient MakeCredential only, CableTransports::CloudAssistedOrLocal.
The QR offers both the WebSocket tunnel and the BLE L2CAP channel; the
authenticator picks one (CTAP 2.3-aware peers may open L2CAP, caBLE v2
peers silently ignore key 6 and fall back to the WebSocket tunnel).
After a successful CTAP ceremony the peer typically tears down the BLE
link without a clean L2CAP shutdown, so our read loop wakes up with
ECONNRESET (or EPIPE / UnexpectedEof) rather than n=0. Treat those the
same way we already treat a clean EOF: return Ok(None) when nothing is
buffered, ConnectionLost when half a message is. Stops the spurious
'Failed to read L2CAP message: ConnectionReset' error log that fires
after a successful MakeCredential.
CableQrCodeDevice's fields (NonZeroScalar, ByteArray, Option<Arc<dyn
CableKnownDeviceInfoStore>>) all auto-derive Send + Sync, so the
hand-rolled unsafe impls are dead. Verified compile-time via a
T: Send + Sync assertion in the test module; downstream consumers
(credentialsd spawns CableQrCodeDevice across tokio::spawn) keep working.

Also brings the top-of-README transport-support table in line with the
example table further down — caBLE v2 plus CTAP 2.3 hybrid.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants