Skip to main content

keyroost_hid/
lib.rs

1//! USB HID enumeration for FIDO / security-key devices.
2//!
3//! On **Linux** this enumerates `/dev/hidraw*` device nodes by reading sysfs
4//! metadata — no external dependencies, no ioctls, no device-open required.
5//! That keeps enumeration root-free and means it works even when the user has
6//! not yet installed the udev rules in `udev/70-keyroost-fido.rules`.
7//!
8//! On **macOS and Windows** it uses the `hidapi` crate (IOKit / hid.dll),
9//! selected automatically off Linux. The `hidapi-backend` feature forces that
10//! path on for building/testing the cross-platform backend on Linux too. USB
11//! topology (`usb_bus`/`usb_address`) is only available via the sysfs backend.
12
13use std::fmt;
14#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
15use std::fs;
16use std::io;
17#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
18use std::path::Path;
19use std::path::PathBuf;
20
21/// HID usage page assigned to FIDO U2F / CTAP HID by usb.org.
22pub const HID_USAGE_PAGE_FIDO: u16 = 0xF1D0;
23/// HID usage within the FIDO page used by U2F / CTAP HID authenticators.
24pub const HID_USAGE_FIDO_AUTHENTICATOR: u16 = 0x01;
25
26/// Things that can go wrong enumerating HID devices.
27#[derive(Debug)]
28pub enum HidError {
29    /// Underlying filesystem error reading sysfs or `/dev`.
30    Io(io::Error),
31    /// A sysfs file existed but was structured unexpectedly.
32    Parse(&'static str),
33    /// The platform HID backend (hidapi, on macOS/Windows) reported an error.
34    Backend(String),
35}
36
37impl fmt::Display for HidError {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            HidError::Io(e) => write!(f, "HID I/O error: {}", e),
41            HidError::Parse(s) => write!(f, "HID parse error: {}", s),
42            HidError::Backend(s) => write!(f, "HID backend error: {}", s),
43        }
44    }
45}
46
47impl std::error::Error for HidError {
48    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49        match self {
50            HidError::Io(e) => Some(e),
51            HidError::Parse(_) | HidError::Backend(_) => None,
52        }
53    }
54}
55
56impl From<io::Error> for HidError {
57    fn from(e: io::Error) -> Self {
58        HidError::Io(e)
59    }
60}
61
62/// Metadata for a single connected HID device.
63#[derive(Debug, Clone)]
64pub struct HidDevice {
65    /// `/dev/hidraw*` path the device is exposed under.
66    pub path: PathBuf,
67    /// USB / Bluetooth vendor ID.
68    pub vendor_id: u16,
69    /// USB / Bluetooth product ID.
70    pub product_id: u16,
71    /// Human-readable product string from the kernel's HID name.
72    pub product_name: String,
73    /// Top-level HID usage page from the report descriptor.
74    pub usage_page: u16,
75    /// Top-level HID usage from the report descriptor.
76    pub usage: u16,
77    /// USB device serial number (`iSerialNumber`), if the device exposes one.
78    /// SoloKeys / Nitrokey publish a unique serial here; many YubiKeys omit it
79    /// (their serial is only reachable via the management applet over CCID).
80    pub serial_number: Option<String>,
81    /// USB bus number (`busnum`) of the underlying device, if known. Together
82    /// with [`Self::usb_address`] this identifies the physical USB device, which
83    /// lets a caller match this hidraw node to the same key's CCID reader (whose
84    /// PC/SC `CHANNEL_ID` encodes the same bus/address).
85    pub usb_bus: Option<u8>,
86    /// USB device address (`devnum`) of the underlying device, if known.
87    pub usb_address: Option<u8>,
88}
89
90/// Known USB `(vendor, product, description)` IDs of security keys sitting in
91/// bootloader / DFU mode. Such a device enumerates as plain HID with no FIDO
92/// usage page and cannot speak CTAP, so it would otherwise silently vanish from
93/// FIDO lists. Solo 2 / Nitrokey 3 share the Trussed bootloader (`1209:b000`).
94const KNOWN_BOOTLOADERS: &[(u16, u16, &str)] =
95    &[(0x1209, 0xb000, "Solo 2 / Nitrokey 3 in bootloader/DFU mode")];
96
97impl HidDevice {
98    /// True when the device advertises the FIDO usage page (`0xF1D0`).
99    pub fn is_fido(&self) -> bool {
100        self.usage_page == HID_USAGE_PAGE_FIDO
101    }
102
103    /// If this device is a recognized security key in bootloader / DFU mode,
104    /// returns a human-readable description. Such a device can't speak FIDO/CTAP
105    /// until it's returned to application mode (typically by re-plugging), so
106    /// callers can message this clearly instead of hanging on a CTAPHID INIT or
107    /// reporting "no FIDO devices" with no explanation.
108    pub fn bootloader_label(&self) -> Option<&'static str> {
109        KNOWN_BOOTLOADERS
110            .iter()
111            .find(|(vid, pid, _)| *vid == self.vendor_id && *pid == self.product_id)
112            .map(|(_, _, label)| *label)
113    }
114}
115
116/// Scan all connected HID devices for any recognized security key in
117/// bootloader / DFU mode, returning the first match's description. A front-end
118/// that finds no FIDO devices can call this to explain why (e.g. a Solo 2 stuck
119/// in DFU) rather than just reporting an empty list.
120pub fn bootloader_device_present() -> Option<&'static str> {
121    enumerate()
122        .ok()?
123        .iter()
124        .find_map(HidDevice::bootloader_label)
125}
126
127/// Whether this platform has a HID backend: Linux (sysfs), macOS (IOKit via
128/// hidapi), and Windows (hid.dll via hidapi). When `false`, [`enumerate`]
129/// returns an empty list and FIDO/CTAP is unavailable — front-ends should say
130/// so explicitly rather than reporting "no FIDO devices", which would imply none
131/// are plugged in.
132#[must_use]
133pub fn hid_supported() -> bool {
134    cfg!(any(
135        target_os = "linux",
136        target_os = "macos",
137        target_os = "windows"
138    ))
139}
140
141/// List all `/dev/hidraw*` devices visible to the current user via sysfs.
142///
143/// Devices the caller lacks permission to *open* are still returned —
144/// enumeration reads sysfs only. Returns an empty list on platforms without a
145/// HID backend (see [`hid_supported`]).
146pub fn enumerate() -> Result<Vec<HidDevice>, HidError> {
147    // Linux uses the dependency-free sysfs backend; macOS/Windows use hidapi.
148    // The `hidapi-backend` feature forces hidapi on (for building/testing the
149    // cross-platform path on Linux too).
150    #[cfg(any(not(target_os = "linux"), feature = "hidapi-backend"))]
151    {
152        enumerate_hidapi()
153    }
154    #[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
155    {
156        enumerate_sysfs()
157    }
158}
159
160/// hidapi-backed enumeration for macOS / Windows (and Linux under the
161/// `hidapi-backend` feature). USB topology (`usb_bus` / `usb_address`) isn't
162/// exposed portably by hidapi, so it's left `None` — the HID↔CCID correlation
163/// that uses it degrades gracefully to serial/identity matching.
164#[cfg(any(not(target_os = "linux"), feature = "hidapi-backend"))]
165fn enumerate_hidapi() -> Result<Vec<HidDevice>, HidError> {
166    let api = hidapi::HidApi::new().map_err(|e| HidError::Backend(e.to_string()))?;
167    let mut devices: Vec<HidDevice> = api
168        .device_list()
169        .map(|info| HidDevice {
170            path: PathBuf::from(info.path().to_string_lossy().into_owned()),
171            vendor_id: info.vendor_id(),
172            product_id: info.product_id(),
173            product_name: info.product_string().unwrap_or_default().to_string(),
174            usage_page: info.usage_page(),
175            usage: info.usage(),
176            serial_number: info.serial_number().map(str::to_owned),
177            usb_bus: None,
178            usb_address: None,
179        })
180        .collect();
181    devices.sort_by(|a, b| a.path.cmp(&b.path));
182    Ok(devices)
183}
184
185/// Dependency-free Linux backend: enumerate `/dev/hidraw*` via sysfs metadata.
186#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
187fn enumerate_sysfs() -> Result<Vec<HidDevice>, HidError> {
188    let entries = match fs::read_dir("/sys/class/hidraw") {
189        Ok(e) => e,
190        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
191        Err(e) => return Err(HidError::Io(e)),
192    };
193
194    let mut devices = Vec::new();
195    for entry in entries {
196        let entry = entry?;
197        let name = entry.file_name();
198        let Some(name_str) = name.to_str() else {
199            continue;
200        };
201        if !name_str.starts_with("hidraw") {
202            continue;
203        }
204        if let Ok(dev) = read_one(name_str, &entry.path()) {
205            devices.push(dev);
206        }
207    }
208    devices.sort_by(|a, b| a.path.cmp(&b.path));
209    Ok(devices)
210}
211
212#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
213fn read_one(name: &str, sysfs: &Path) -> Result<HidDevice, HidError> {
214    let uevent = fs::read_to_string(sysfs.join("device/uevent"))?;
215    let mut vendor_id: u16 = 0;
216    let mut product_id: u16 = 0;
217    let mut product_name = String::new();
218    for line in uevent.lines() {
219        if let Some(rest) = line.strip_prefix("HID_ID=") {
220            let parts: Vec<&str> = rest.split(':').collect();
221            if parts.len() != 3 {
222                return Err(HidError::Parse("HID_ID format"));
223            }
224            vendor_id = parse_hex_u16(parts[1]).ok_or(HidError::Parse("HID_ID vendor"))?;
225            product_id = parse_hex_u16(parts[2]).ok_or(HidError::Parse("HID_ID product"))?;
226        } else if let Some(rest) = line.strip_prefix("HID_NAME=") {
227            product_name = rest.to_string();
228        }
229    }
230
231    let report_desc = fs::read(sysfs.join("device/report_descriptor")).unwrap_or_default();
232    let (usage_page, usage) = parse_top_usage(&report_desc).unwrap_or((0, 0));
233
234    // Locate the backing USB device node once and read its serial + topology.
235    let (serial_number, usb_bus, usb_address) = match usb_device_dir(&sysfs.join("device")) {
236        Some(dir) => (
237            read_usb_serial(&dir),
238            read_sysfs_u8(&dir.join("busnum")),
239            read_sysfs_u8(&dir.join("devnum")),
240        ),
241        None => (None, None, None),
242    };
243
244    Ok(HidDevice {
245        path: PathBuf::from(format!("/dev/{}", name)),
246        vendor_id,
247        product_id,
248        product_name,
249        usage_page,
250        usage,
251        serial_number,
252        usb_bus,
253        usb_address,
254    })
255}
256
257/// Walk up the sysfs tree from a HID device link to the first ancestor carrying
258/// an `idVendor` file — that's the backing USB device node. Returns `None` on a
259/// non-USB transport (e.g. Bluetooth) or any read error.
260#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
261fn usb_device_dir(device_link: &Path) -> Option<PathBuf> {
262    let mut dir = fs::canonicalize(device_link).ok()?;
263    loop {
264        if dir.join("idVendor").exists() {
265            return Some(dir);
266        }
267        dir = dir.parent()?.to_path_buf();
268    }
269}
270
271/// Read the USB device serial (`iSerialNumber`) from a USB device node.
272/// Returns `None` when the descriptor carries no serial (many YubiKeys) or the
273/// attribute can't be read.
274#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
275fn read_usb_serial(usb_dir: &Path) -> Option<String> {
276    let serial = fs::read_to_string(usb_dir.join("serial")).ok()?;
277    let serial = serial.trim();
278    (!serial.is_empty()).then(|| serial.to_string())
279}
280
281/// Read a small decimal sysfs attribute (e.g. `busnum`, `devnum`) as a `u8`.
282#[cfg(all(target_os = "linux", not(feature = "hidapi-backend")))]
283fn read_sysfs_u8(path: &Path) -> Option<u8> {
284    fs::read_to_string(path).ok()?.trim().parse().ok()
285}
286
287// Used by the sysfs backend and the tests; dead on the hidapi-only build.
288#[cfg_attr(
289    any(not(target_os = "linux"), feature = "hidapi-backend"),
290    allow(dead_code)
291)]
292fn parse_hex_u16(s: &str) -> Option<u16> {
293    // Sysfs HID_ID fields are 8 hex chars wide; only the low 16 bits are the VID/PID.
294    let v = u32::from_str_radix(s.trim(), 16).ok()?;
295    Some((v & 0xFFFF) as u16)
296}
297
298/// Walk a HID report descriptor and return the first
299/// `(usage_page, usage)` pair, which describes the device's top-level
300/// application collection.
301// Used by the sysfs backend and the tests; dead on the hidapi-only build.
302#[cfg_attr(
303    any(not(target_os = "linux"), feature = "hidapi-backend"),
304    allow(dead_code)
305)]
306fn parse_top_usage(desc: &[u8]) -> Option<(u16, u16)> {
307    let mut i = 0;
308    let mut usage_page: Option<u16> = None;
309
310    while i < desc.len() {
311        let prefix = desc[i];
312        // Long items (rare): prefix 0xFE, then bSize, bTag, data.
313        if prefix == 0xFE {
314            if i + 1 >= desc.len() {
315                break;
316            }
317            let size = desc[i + 1] as usize;
318            i = i.saturating_add(3).saturating_add(size);
319            continue;
320        }
321        let size = match prefix & 0b11 {
322            0 => 0,
323            1 => 1,
324            2 => 2,
325            3 => 4,
326            _ => 0,
327        };
328        let typ = (prefix >> 2) & 0b11;
329        let tag = (prefix >> 4) & 0xF;
330
331        if i + 1 + size > desc.len() {
332            break;
333        }
334        let data = &desc[i + 1..i + 1 + size];
335        let value: u32 = match size {
336            0 => 0,
337            1 => data[0] as u32,
338            2 => u16::from_le_bytes([data[0], data[1]]) as u32,
339            4 => u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
340            _ => 0,
341        };
342
343        // typ=1 (Global), tag=0 → Usage Page
344        if typ == 1 && tag == 0 {
345            usage_page = Some((value & 0xFFFF) as u16);
346        }
347        // typ=2 (Local), tag=0 → Usage
348        if typ == 2 && tag == 0 {
349            if let Some(page) = usage_page {
350                return Some((page, (value & 0xFFFF) as u16));
351            }
352        }
353
354        i += 1 + size;
355    }
356
357    usage_page.map(|p| (p, 0))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn hid_id_field_parses_8_char_hex() {
366        assert_eq!(parse_hex_u16("00001050"), Some(0x1050));
367        assert_eq!(parse_hex_u16("00000407"), Some(0x0407));
368        assert_eq!(parse_hex_u16("1050"), Some(0x1050));
369        assert!(parse_hex_u16("xyz").is_none());
370    }
371
372    #[test]
373    fn fido_descriptor_yields_f1d0_01() {
374        // Usage Page (FIDO 0xF1D0); Usage (Authenticator 0x01); Collection (App)
375        let desc = [0x06, 0xD0, 0xF1, 0x09, 0x01, 0xA1, 0x01];
376        let (page, usage) = parse_top_usage(&desc).expect("usage pair present");
377        assert_eq!(page, 0xF1D0);
378        assert_eq!(usage, 0x01);
379    }
380
381    #[test]
382    fn keyboard_descriptor_yields_generic_desktop_keyboard() {
383        // Usage Page (Generic Desktop 0x01); Usage (Keyboard 0x06)
384        let desc = [0x05, 0x01, 0x09, 0x06];
385        let (page, usage) = parse_top_usage(&desc).expect("usage pair present");
386        assert_eq!(page, 0x01);
387        assert_eq!(usage, 0x06);
388    }
389
390    #[test]
391    fn empty_descriptor_yields_none() {
392        assert!(parse_top_usage(&[]).is_none());
393    }
394
395    #[test]
396    fn fido_helper_only_matches_fido_page() {
397        let fido = HidDevice {
398            path: PathBuf::from("/dev/hidraw0"),
399            vendor_id: 0x1050,
400            product_id: 0x0407,
401            product_name: "YubiKey".into(),
402            usage_page: HID_USAGE_PAGE_FIDO,
403            usage: HID_USAGE_FIDO_AUTHENTICATOR,
404            serial_number: None,
405            usb_bus: None,
406            usb_address: None,
407        };
408        let kbd = HidDevice {
409            usage_page: 0x01,
410            ..fido.clone()
411        };
412        assert!(fido.is_fido());
413        assert!(!kbd.is_fido());
414    }
415
416    #[test]
417    fn bootloader_label_matches_known_dfu_id() {
418        let fido = HidDevice {
419            path: PathBuf::from("/dev/hidraw0"),
420            vendor_id: 0x1050,
421            product_id: 0x0407,
422            product_name: "YubiKey".into(),
423            usage_page: HID_USAGE_PAGE_FIDO,
424            usage: HID_USAGE_FIDO_AUTHENTICATOR,
425            serial_number: None,
426            usb_bus: None,
427            usb_address: None,
428        };
429        // A normal FIDO key is not a bootloader.
430        assert!(fido.bootloader_label().is_none());
431        // Solo 2 / Nitrokey 3 in DFU mode (1209:b000) is recognized.
432        let dfu = HidDevice {
433            vendor_id: 0x1209,
434            product_id: 0xb000,
435            usage_page: 0x01,
436            ..fido
437        };
438        assert!(dfu.bootloader_label().is_some());
439    }
440}