Skip to content

Commit cf2b40b

Browse files
feat(nfc): dedupe USB-HID authenticators (#270)
Some security keys appear twice when we go looking for authenticators. The same key shows up once as a USB device over HID, and again as a PC/SC reader for its built-in smart-card interface. Both entries are the same physical key, but until now nothing connected them, so a single key plugged into USB also looked like a separate NFC authenticator waiting to be used. We now recognize the two entries as one device by comparing the USB address behind each. When a reader resolves to the same physical USB device as a key we already see over HID, we drop it from the NFC list. A key plugged in over USB is no longer offered as a separate NFC device. Reading real NFC keys is unchanged. A key held to a contactless reader has no matching USB device, so it stays in the list and is read exactly as before. Relates to #182
1 parent d351aab commit cf2b40b

7 files changed

Lines changed: 237 additions & 8 deletions

File tree

libwebauthn/examples/ceremony/webauthn_nfc.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use libwebauthn::ops::webauthn::{
44
GetAssertionRequest, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins,
55
RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _,
66
};
7-
use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available};
8-
use libwebauthn::transport::{Channel as _, ChannelSettings, Device};
7+
use libwebauthn::transport::nfc::NfcDeviceSliceExt;
8+
use libwebauthn::transport::{hid, nfc, Channel as _, ChannelSettings, Device};
99
use libwebauthn::webauthn::WebAuthn;
1010

1111
#[path = "../common/mod.rs"]
@@ -15,16 +15,38 @@ mod common;
1515
pub async fn main() -> Result<(), Box<dyn Error>> {
1616
common::setup_logging();
1717

18-
if !is_nfc_available() {
18+
if !nfc::is_nfc_available() {
1919
println!("No NFC-Reader found. NFC is not available on your system.");
2020
return Err("NFC not available".into());
2121
}
2222

23-
let Some(mut device) = get_nfc_device().await? else {
23+
// A USB key's CCID interface also shows up as a PC/SC reader, so drop the
24+
// NFC entries that duplicate a connected HID key.
25+
let hid_devices = hid::list_devices().await.unwrap_or_default();
26+
let nfc_devices = nfc::list_devices().await;
27+
let discovered = nfc_devices.len();
28+
let nfc_devices = nfc_devices.without_hid_duplicates(&hid_devices);
29+
println!(
30+
"Discovered {discovered} NFC device(s); dropped {} that duplicate a connected HID key.",
31+
discovered - nfc_devices.len()
32+
);
33+
34+
// Unfiltered, so keep the first survivor that opens a FIDO channel.
35+
let mut selected = None;
36+
for mut device in nfc_devices {
37+
match device.channel(ChannelSettings::default()).await {
38+
Ok(channel) => {
39+
println!("Selected NFC authenticator: {device}");
40+
selected = Some(channel);
41+
break;
42+
}
43+
Err(error) => println!("Skipping NFC reader (no FIDO applet): {error}"),
44+
}
45+
}
46+
let Some(mut channel) = selected else {
47+
println!("No FIDO NFC authenticator found after de-duplication.");
2448
return Ok(());
2549
};
26-
println!("Selected NFC authenticator: {}", device);
27-
let mut channel = device.channel(ChannelSettings::default()).await?;
2850

2951
let request_origin: RequestOrigin = "https://example.org".try_into().expect("Invalid origin");
3052
let psl = SystemPublicSuffixList::auto().expect(

libwebauthn/src/transport/hid/device.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use tracing::{debug, info, instrument};
1212
#[cfg(feature = "virt")]
1313
use super::framing::HidMessage;
1414
use crate::transport::error::TransportError;
15+
use crate::transport::usb::{usb_id_from_hidraw, UsbDeviceId};
1516
use crate::transport::{ChannelSettings, Device};
1617
use crate::webauthn::error::Error;
1718

@@ -41,6 +42,17 @@ impl From<&DeviceInfo> for HidDevice {
4142
}
4243
}
4344

45+
impl HidDevice {
46+
/// The USB (bus, address) backing this key, when it can be resolved.
47+
pub fn usb_device_id(&self) -> Option<UsbDeviceId> {
48+
match &self.backend {
49+
HidBackendDevice::HidApiDevice(info) => usb_id_from_hidraw(info.path()),
50+
#[cfg(feature = "virt")]
51+
HidBackendDevice::VirtualDevice(_) => None,
52+
}
53+
}
54+
}
55+
4456
impl fmt::Display for HidDevice {
4557
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4658
match &self.backend {

libwebauthn/src/transport/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub mod nfc {
3030
false
3131
}
3232
}
33+
pub mod usb;
3334

3435
mod channel;
3536
#[allow(clippy::module_inception)]
@@ -43,3 +44,4 @@ pub use channel::ChannelStatus;
4344

4445
pub use device::Device;
4546
pub use transport::Transport;
47+
pub use usb::UsbDeviceId;

libwebauthn/src/transport/nfc/device.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use async_trait::async_trait;
2+
use std::collections::HashSet;
23
use std::fmt;
34
#[allow(unused_imports)]
45
use tracing::{debug, info, instrument, trace};
56

67
use crate::{
7-
transport::{device::Device, Channel, ChannelSettings},
8+
transport::{device::Device, hid::HidDevice, Channel, ChannelSettings, UsbDeviceId},
89
webauthn::Error,
910
};
1011

@@ -60,6 +61,17 @@ impl NfcDevice {
6061
}
6162
}
6263

64+
/// The USB (bus, address) backing this device, when it can be resolved.
65+
/// Performs blocking PC/SC I/O (a `Direct`-mode connect).
66+
pub fn usb_device_id(&self) -> Option<UsbDeviceId> {
67+
match &self.info {
68+
#[cfg(feature = "nfc-backend-pcsc")]
69+
DeviceInfo::Pcsc(info) => info.usb_device_id(),
70+
#[cfg(feature = "nfc-backend-libnfc")]
71+
DeviceInfo::LibNfc(_) => None,
72+
}
73+
}
74+
6375
async fn channel_sync(&self, settings: ChannelSettings) -> Result<NfcChannel<Context>, Error> {
6476
trace!("nfc channel {:?}", self);
6577
let mut channel: NfcChannel<Context> = match &self.info {
@@ -135,3 +147,65 @@ pub fn is_nfc_available() -> bool {
135147

136148
available
137149
}
150+
151+
/// Lists all NFC devices from the compiled backends, unfiltered. A failing
152+
/// backend is skipped. Cross-backend duplicates are not removed.
153+
#[instrument]
154+
pub async fn list_devices() -> Vec<NfcDevice> {
155+
#[allow(unused_mut)]
156+
let mut devices = Vec::new();
157+
#[cfg(feature = "nfc-backend-libnfc")]
158+
if let Ok(found) = libnfc::list_devices() {
159+
devices.extend(found);
160+
}
161+
#[cfg(feature = "nfc-backend-pcsc")]
162+
if let Ok(found) = pcsc::list_devices() {
163+
devices.extend(found);
164+
}
165+
devices
166+
}
167+
168+
/// Drops NFC devices that are the CCID face of a USB key already seen over HID,
169+
/// matched by USB (bus, address). Does blocking PC/SC I/O per reader.
170+
pub trait NfcDeviceSliceExt {
171+
fn without_hid_duplicates(&self, hid: &[HidDevice]) -> Vec<NfcDevice>;
172+
}
173+
174+
impl NfcDeviceSliceExt for [NfcDevice] {
175+
fn without_hid_duplicates(&self, hid: &[HidDevice]) -> Vec<NfcDevice> {
176+
let hid_ids: HashSet<UsbDeviceId> =
177+
hid.iter().filter_map(HidDevice::usb_device_id).collect();
178+
let nfc_ids: Vec<Option<UsbDeviceId>> = self.iter().map(NfcDevice::usb_device_id).collect();
179+
self.iter()
180+
.zip(dedup_keep_mask(&nfc_ids, &hid_ids))
181+
.filter(|&(_, keep)| keep)
182+
.map(|(device, _)| device.clone())
183+
.collect()
184+
}
185+
}
186+
187+
/// Pure dedup core, unit-testable without hardware.
188+
fn dedup_keep_mask(nfc_ids: &[Option<UsbDeviceId>], hid_ids: &HashSet<UsbDeviceId>) -> Vec<bool> {
189+
nfc_ids
190+
.iter()
191+
.map(|id| match id {
192+
Some(id) => !hid_ids.contains(id),
193+
None => true,
194+
})
195+
.collect()
196+
}
197+
198+
#[cfg(test)]
199+
mod tests {
200+
use super::*;
201+
202+
#[test]
203+
fn dedup_keeps_non_duplicates_and_unknown() {
204+
let a = UsbDeviceId { bus: 1, address: 8 };
205+
let b = UsbDeviceId { bus: 1, address: 9 };
206+
let nfc_ids = [Some(a), Some(b), None];
207+
let hid_ids: HashSet<UsbDeviceId> = [a].into_iter().collect();
208+
209+
assert_eq!(dedup_keep_mask(&nfc_ids, &hid_ids), vec![false, true, true]);
210+
}
211+
}

libwebauthn/src/transport/nfc/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pub mod libnfc;
88
#[cfg(feature = "nfc-backend-pcsc")]
99
pub mod pcsc;
1010

11-
pub use device::{get_nfc_device, is_nfc_available};
11+
pub use device::{get_nfc_device, is_nfc_available, list_devices, NfcDevice, NfcDeviceSliceExt};
1212

1313
use super::Transport;
1414

libwebauthn/src/transport/nfc/pcsc/mod.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use super::channel::{HandlerInCtx, NfcBackend, NfcChannel};
22
use super::device::NfcDevice;
33
use super::Context;
44
use crate::transport::error::TransportError;
5+
use crate::transport::usb::UsbDeviceId;
56
use crate::transport::ChannelSettings;
67
use crate::webauthn::Error;
78
use apdu::core::HandleError;
@@ -93,6 +94,33 @@ impl Info {
9394
let channel = NfcChannel::new(Box::new(chan), ctx, settings);
9495
Ok(channel)
9596
}
97+
98+
pub(crate) fn usb_device_id(&self) -> Option<UsbDeviceId> {
99+
usb_id_from_reader(&self.name)
100+
}
101+
}
102+
103+
/// Reads the reader's `SCARD_ATTR_CHANNEL_ID` to get its USB (bus, address).
104+
/// Connects in `Direct` mode so no card is required and none is reset.
105+
pub(crate) fn usb_id_from_reader(name: &CStr) -> Option<UsbDeviceId> {
106+
let context = pcsc::Context::establish(pcsc::Scope::User).ok()?;
107+
let card = context
108+
.connect(name, pcsc::ShareMode::Direct, pcsc::Protocols::UNDEFINED)
109+
.ok()?;
110+
111+
let mut buf = [0u8; 8];
112+
let id = card
113+
.get_attribute(pcsc::Attribute::ChannelId, &mut buf)
114+
.ok()
115+
.and_then(|attr| attr.get(..4)?.try_into().ok())
116+
.and_then(UsbDeviceId::from_channel_id_bytes);
117+
118+
// If disconnect fails, forget the returned Card so its Drop cannot reset an
119+
// inserted card. The context release below frees the handle.
120+
if let Err((card, _)) = card.disconnect(pcsc::Disposition::LeaveCard) {
121+
std::mem::forget(card);
122+
}
123+
id
96124
}
97125

98126
impl Channel {

libwebauthn/src/transport/usb.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! Identifies a physical USB device by its (bus, address), which is shared by
2+
//! all of its interfaces. Lets us recognise one key seen over both HID and PC/SC.
3+
4+
/// A physical USB device, identified by its (bus, address) pair.
5+
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
6+
pub struct UsbDeviceId {
7+
pub bus: u8,
8+
pub address: u8,
9+
}
10+
11+
impl UsbDeviceId {
12+
/// Decodes a `SCARD_ATTR_CHANNEL_ID` DWORD (USB marker `0x0020`, then `bus << 8 | address`).
13+
#[cfg_attr(not(feature = "nfc-backend-pcsc"), allow(dead_code))]
14+
pub(crate) fn from_channel_id(dword: u32) -> Option<Self> {
15+
if (dword >> 16) != 0x0020 {
16+
return None;
17+
}
18+
Some(Self {
19+
bus: ((dword & 0xffff) >> 8) as u8,
20+
address: (dword & 0xff) as u8,
21+
})
22+
}
23+
24+
/// Decodes the 4 channel-id bytes, trying both byte orders.
25+
#[cfg_attr(not(feature = "nfc-backend-pcsc"), allow(dead_code))]
26+
pub(crate) fn from_channel_id_bytes(bytes: [u8; 4]) -> Option<Self> {
27+
let dword = u32::from_ne_bytes(bytes);
28+
Self::from_channel_id(dword).or_else(|| Self::from_channel_id(dword.swap_bytes()))
29+
}
30+
}
31+
32+
/// Resolves the USB (bus, address) behind a hidraw node via sysfs.
33+
pub(crate) fn usb_id_from_hidraw(path: &std::ffi::CStr) -> Option<UsbDeviceId> {
34+
use std::ffi::OsStr;
35+
use std::os::unix::ffi::OsStrExt;
36+
use std::path::{Path, PathBuf};
37+
38+
let name = Path::new(OsStr::from_bytes(path.to_bytes())).file_name()?;
39+
let mut dir: PathBuf =
40+
std::fs::canonicalize(Path::new("/sys/class/hidraw").join(name).join("device")).ok()?;
41+
42+
loop {
43+
let busnum = dir.join("busnum");
44+
let devnum = dir.join("devnum");
45+
if busnum.is_file() && devnum.is_file() {
46+
return Some(UsbDeviceId {
47+
bus: read_sysfs_u8(&busnum)?,
48+
address: read_sysfs_u8(&devnum)?,
49+
});
50+
}
51+
if !dir.pop() {
52+
return None;
53+
}
54+
}
55+
}
56+
57+
fn read_sysfs_u8(path: &std::path::Path) -> Option<u8> {
58+
// >255 yields None: keep the device rather than risk a false match.
59+
let value: u32 = std::fs::read_to_string(path).ok()?.trim().parse().ok()?;
60+
u8::try_from(value).ok()
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::*;
66+
67+
#[test]
68+
fn from_channel_id_decodes_usb() {
69+
let id = UsbDeviceId::from_channel_id(0x0020_0108).expect("USB marker");
70+
assert_eq!(id.bus, 1);
71+
assert_eq!(id.address, 8);
72+
}
73+
74+
#[test]
75+
fn from_channel_id_rejects_non_usb() {
76+
assert!(UsbDeviceId::from_channel_id(0x0010_0108).is_none());
77+
}
78+
79+
#[test]
80+
fn from_channel_id_bytes_decodes_either_byte_order() {
81+
let want = UsbDeviceId { bus: 1, address: 8 };
82+
assert_eq!(
83+
UsbDeviceId::from_channel_id_bytes([0x08, 0x01, 0x20, 0x00]),
84+
Some(want)
85+
);
86+
assert_eq!(
87+
UsbDeviceId::from_channel_id_bytes([0x00, 0x20, 0x01, 0x08]),
88+
Some(want)
89+
);
90+
}
91+
}

0 commit comments

Comments
 (0)