Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/rust-latest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ jobs:
run: cargo test -p libwebauthn --lib --features nfc-backend-pcsc --verbose
env:
LIBWEBAUTHN_PSL_SYSTEM_TEST: "1"
- name: Verify libwebauthn publishes cleanly
run: cargo publish --dry-run -p libwebauthn
- name: Verify libwebauthn and libwebauthn-pxp publish cleanly
run: cargo publish --dry-run -p libwebauthn-pxp -p libwebauthn
4 changes: 2 additions & 2 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ jobs:
run: cargo test -p libwebauthn --lib --features nfc-backend-pcsc --verbose
env:
LIBWEBAUTHN_PSL_SYSTEM_TEST: "1"
- name: Verify libwebauthn publishes cleanly
run: cargo publish --dry-run -p libwebauthn
- name: Verify libwebauthn and libwebauthn-pxp publish cleanly
run: cargo publish --dry-run -p libwebauthn-pxp -p libwebauthn

msrv:
name: Verify MSRV
Expand Down
39 changes: 34 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["libwebauthn", "libwebauthn-tests"]
members = ["libwebauthn", "libwebauthn-pxp", "libwebauthn-tests"]

# The trussed ecosystem is currently a bit messy, so we have to do some patching of the versions
[patch.crates-io]
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ opt-in: the crate ships with `default = []` for the NFC stack, so enable the
`nfc-backend-pcsc` feature (pure userspace, recommended) or `nfc-backend-libnfc`
(requires the `libnfc` system library) to compile it in.

The hybrid transports are built on the standalone
[`libwebauthn-pxp`](libwebauthn-pxp) crate, which implements the FIDO
[Proximity Exchange Protocol][pxp] (formerly CTAP hybrid).

[^nfc-optin]: Off by default. Enable `nfc-backend-pcsc` and/or `nfc-backend-libnfc`.

## Example programs
Expand Down Expand Up @@ -137,3 +141,4 @@ If you don't know where to start, check out the _Issues_ tab.
[#18]: https://github.com/linux-credentials/libwebauthn/issues/18
[#31]: https://github.com/linux-credentials/libwebauthn/issues/31
[psl]: https://publicsuffix.org/
[pxp]: https://fidoalliance.org/specs/hybrid/proximity-exchange-protocol-v1.0-wd-20260717.html
53 changes: 53 additions & 0 deletions libwebauthn-pxp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[package]
name = "libwebauthn-pxp"
description = "FIDO Proximity Exchange Protocol (PXP, formerly CTAP hybrid / caBLE) client for Linux, written in Rust"
version = "0.10.0"
authors = [
"Alfie Fresta <afresta@noentropy.org>",
"Martin Sirringhaus <martin.sirringhaus@suse.com>",
"Isaiah Inuwa <isaiah.inuwa@gmail.com>",
]
edition = "2021"
rust-version = "1.88"
license = "LGPL-2.1-or-later"
license-file = "../COPYING"
readme = "../README.md"
homepage = "https://github.com/linux-credentials"
repository = "https://github.com/linux-credentials/libwebauthn"

[lib]
name = "libwebauthn_pxp"
path = "src/lib.rs"

[dependencies]
async-trait = "0.1.36"
base64-url = "3.0.0"
bluer = { version = "0.17", default-features = false, features = ["l2cap"] }
btleplug = "0.11.7"
aes = "0.8.2"
futures = "0.3.5"
hex = "0.4.3"
hkdf = "0.12"
hmac = "0.12.1"
p256 = { version = "0.13.2", features = ["ecdh", "arithmetic", "serde"] }
rand = "0.8.5"
rustls = { version = "0.23.27", default-features = false, features = ["ring"] }
serde = "1.0.110"
serde_bytes = "0.11.5"
serde_cbor_2 = "0.13"
serde-indexed = "0.2.0"
serde_repr = "0.1.6"
sha2 = "0.10.2"
snow = { version = "0.10", features = ["use-p256"] }
thiserror = "2.0.12"
tokio = { version = "1.45", features = ["full"] }
tokio-tungstenite = { version = "0.26", features = [
"rustls-tls-native-roots",
] }
tracing = "0.1.29"
tungstenite = { version = "0.26.2" }
url = "2.5"
uuid = { version = "1.5.0", features = ["serde", "v4"] }

[dev-dependencies]
test-log = { version = "0.2" }
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
use std::collections::BTreeMap;

use ::btleplug::api::Central;
use ::btleplug::api::{Central, PeripheralProperties};
use futures::StreamExt;
use serde_cbor_2 as serde_cbor;
use std::pin::pin;
use tracing::{debug, instrument, trace, warn};
use uuid::Uuid;

use crate::proto::ctap2::cbor::Value;
use crate::transport::ble::btleplug::{self, FidoDevice};
use crate::transport::cable::crypto::trial_decrypt_advert;
use crate::transport::cable::error::CableError;
use crate::ble;
use crate::cbor::Value;
use crate::crypto::trial_decrypt_advert;
use crate::error::PxpError;

const CABLE_UUID_FIDO: &str = "0000fff9-0000-1000-8000-00805f9b34fb";
const CABLE_UUID_GOOGLE: &str = "0000fde2-0000-1000-8000-00805f9b34fb";
const PXP_UUID_FIDO: &str = "0000fff9-0000-1000-8000-00805f9b34fb";
const PXP_UUID_GOOGLE: &str = "0000fde2-0000-1000-8000-00805f9b34fb";

/// `transport_channel_identifier` for the BLE data channel.
const TRANSPORT_CHANNEL_BLE: i128 = 1;
Expand Down Expand Up @@ -72,23 +72,18 @@ impl From<[u8; 16]> for DecryptedAdvert {
#[instrument(skip_all, err)]
pub(crate) async fn await_advertisement(
eid_key: &[u8],
) -> Result<(FidoDevice, DecryptedAdvert), CableError> {
) -> Result<(PeripheralProperties, DecryptedAdvert), PxpError> {
let uuids = &[
Uuid::parse_str(CABLE_UUID_FIDO)?,
Uuid::parse_str(CABLE_UUID_GOOGLE)?, // Deprecated, but may still be in use.
Uuid::parse_str(PXP_UUID_FIDO)?,
Uuid::parse_str(PXP_UUID_GOOGLE)?, // Deprecated, but may still be in use.
];
let stream = btleplug::manager::start_discovery_for_service_data(uuids)
.await
.or(Err(CableError::TransportUnavailable))?;
let (adapter, stream) = ble::start_discovery_for_service_data(uuids).await?;

let mut stream = pin!(stream);
while let Some((adapter, peripheral, data)) = stream.as_mut().next().await {
while let Some((peripheral, data)) = stream.as_mut().next().await {
debug!({ ?peripheral, ?data }, "Found device with service data");

let Some(device) = btleplug::manager::get_device(peripheral.clone())
.await
.or(Err(CableError::TransportUnavailable))?
else {
let Some(device) = ble::get_properties(&peripheral).await? else {
warn!(
?peripheral,
"Unable to fetch peripheral properties, ignoring"
Expand Down Expand Up @@ -126,13 +121,13 @@ pub(crate) async fn await_advertisement(
adapter
.stop_scan()
.await
.or(Err(CableError::TransportUnavailable))?;
.or(Err(PxpError::TransportUnavailable))?;

return Ok((device, advert));
}

warn!("BLE advertisement discovery stream terminated");
Err(CableError::TransportUnavailable)
Err(PxpError::TransportUnavailable)
}

#[cfg(test)]
Expand Down
86 changes: 86 additions & 0 deletions libwebauthn-pxp/src/ble.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! Minimal btleplug scan for the CMHD's BLE advertisement. Only service data
//! and the peripheral's properties are needed; no GATT connection is made.
use std::collections::HashMap;

use btleplug::api::{Central as _, CentralEvent, Manager as _, Peripheral as _};
use btleplug::api::{PeripheralProperties, ScanFilter};
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
use futures::{Stream, StreamExt};
use tracing::{debug, instrument, trace, warn, Level};
use uuid::Uuid;

use crate::error::PxpError;

/// TODO(#86): Support multiple adapters.
async fn get_adapter() -> Result<Adapter, PxpError> {
let manager = Manager::new()
.await
.or(Err(PxpError::TransportUnavailable))?;
manager
.adapters()
.await
.or(Err(PxpError::TransportUnavailable))?
.into_iter()
.next()
.ok_or(PxpError::TransportUnavailable)
}

async fn on_peripheral_service_data(
adapter: &Adapter,
id: &PeripheralId,
uuids: &[Uuid],
service_data: HashMap<Uuid, Vec<u8>>,
) -> Option<(Peripheral, Vec<u8>)> {
let data = uuids.iter().find_map(|uuid| service_data.get(uuid))?;
trace!(?id, ?data, "Found service data");

let Ok(peripheral) = adapter.peripheral(id).await else {
warn!(?id, "Could not get peripheral");
return None;
};

debug!({ ?id, ?data }, "Found service data for peripheral");
Some((peripheral, data.to_owned()))
}

/// Scans for peripherals advertising service data on any of `uuids`. Returns
/// the adapter, so the caller can stop the scan, and the matching stream.
#[instrument(level = Level::DEBUG, skip_all)]
pub(crate) async fn start_discovery_for_service_data(
uuids: &[Uuid],
) -> Result<(Adapter, impl Stream<Item = (Peripheral, Vec<u8>)> + use<'_>), PxpError> {
let adapter = get_adapter().await?;
let events = adapter
.events()
.await
.or(Err(PxpError::TransportUnavailable))?;

adapter
.start_scan(ScanFilter::default())
.await
.or(Err(PxpError::TransportUnavailable))?;

let scan_adapter = adapter.clone();
let stream = events.filter_map(move |event| {
let adapter = scan_adapter.clone();
async move {
match event {
CentralEvent::ServiceDataAdvertisement { id, service_data } => {
on_peripheral_service_data(&adapter, &id, uuids, service_data).await
}
_ => None,
}
}
});

Ok((adapter, stream))
}

pub(crate) async fn get_properties(
peripheral: &Peripheral,
) -> Result<Option<PeripheralProperties>, PxpError> {
peripheral
.properties()
.await
.or(Err(PxpError::TransportUnavailable))
}
3 changes: 3 additions & 0 deletions libwebauthn-pxp/src/cbor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! PXP's own CBOR messages (QR data, advert suffix, post-handshake and update
//! messages). CTAP payloads are never decoded here.
pub(crate) use serde_cbor_2::{from_slice, to_vec, Value};
Loading