use std::path::PathBuf;
use keyroost_hid::HidDevice;
use keyroost_keyring::Keyring;
#[derive(Clone, Copy, Default, PartialEq, Eq)]
pub struct Caps(u8);
impl Caps {
pub const FIDO2: Caps = Caps(1 << 0);
pub const OATH: Caps = Caps(1 << 1);
pub const PGP: Caps = Caps(1 << 2);
pub const PIV: Caps = Caps(1 << 3);
pub const TOTP: Caps = Caps(1 << 4); pub const OTP: Caps = Caps(1 << 5);
pub fn has(self, c: Caps) -> bool {
self.0 & c.0 != 0
}
pub fn insert(&mut self, c: Caps) {
self.0 |= c.0;
}
pub fn is_empty(self) -> bool {
self.0 == 0
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
Key,
Token,
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum CapTab {
#[default]
Overview,
Fido2,
Oath,
Pgp,
Piv,
Otp,
}
pub type DeviceId = String;
#[derive(Clone)]
pub struct UiDevice {
pub id: DeviceId,
pub name: Option<String>,
pub vendor: String,
pub model: String,
pub serial: String,
pub transport: String,
pub firmware: String,
pub caps: Caps,
pub kind: DeviceKind,
pub hid_path: Option<PathBuf>,
pub reader: Option<String>,
}
impl UiDevice {
pub fn title(&self) -> &str {
self.name.as_deref().unwrap_or(&self.model)
}
pub fn tabs(&self) -> Vec<CapTab> {
if self.kind == DeviceKind::Token {
return Vec::new();
}
let mut v = vec![CapTab::Overview];
if self.caps.has(Caps::FIDO2) {
v.push(CapTab::Fido2);
}
if self.caps.has(Caps::OATH) {
v.push(CapTab::Oath);
}
if self.caps.has(Caps::PGP) {
v.push(CapTab::Pgp);
}
if self.caps.has(Caps::PIV) {
v.push(CapTab::Piv);
}
if self.caps.has(Caps::OTP) {
v.push(CapTab::Otp);
}
v
}
}
fn vendor_name(vid: u16) -> &'static str {
match vid {
0x1050 => "Yubico",
0x20a0 => "Nitrokey",
0x1209 => "SoloKeys",
0x096e | 0x311f => "Feitian",
0x2581 => "Kanokey",
0x349e => "Token2",
0x1e0d => "OpenSK",
_ => "Security key",
}
}
fn clean_model(raw: &str, vendor: &str) -> String {
let mut s = String::with_capacity(raw.len());
let mut depth = 0i32;
for ch in raw.chars() {
match ch {
'[' | '(' => depth += 1,
']' | ')' => depth = (depth - 1).max(0),
_ if depth == 0 => s.push(ch),
_ => {}
}
}
for junk in [
"CCID/ICCD Interface",
"OTP+FIDO+CCID",
"FIDO+CCID",
"OTP+FIDO",
"U2F+CCID",
"+CCID",
"ICCD",
"CCID",
"Interface",
"Smartcard",
"Smart Card",
] {
s = s.replace(junk, " ");
}
let lead = s.trim_start();
if !vendor.is_empty()
&& lead
.to_ascii_lowercase()
.starts_with(&vendor.to_ascii_lowercase())
{
s = lead[vendor.len()..].to_string();
}
let mut parts: Vec<&str> = s.split_whitespace().collect();
while parts.len() > 1 {
let last = parts[parts.len() - 1];
if last.len() == 2 && last.chars().all(|c| c.is_ascii_digit()) {
parts.pop();
} else {
break;
}
}
let out = parts.join(" ");
if out.is_empty() {
vendor.to_string()
} else {
out
}
}
pub fn enumerate() -> Result<Vec<UiDevice>, String> {
use keyroost_transport::YubiKeyCcid;
let hids: Vec<HidDevice> = keyroost_hid::enumerate()
.map_err(|e| format!("HID enumeration failed: {e}"))?
.into_iter()
.filter(HidDevice::is_fido)
.collect();
let keyring = Keyring::load_default().unwrap_or_default();
let probes = keyroost_transport::probe_readers().unwrap_or_default();
let yk_readers: Vec<YubiKeyCcid> = probes
.iter()
.filter(|p| !p.is_molto2)
.map(|p| YubiKeyCcid {
reader_name: p.reader_name.clone(),
usb_bus: p.usb_bus,
usb_address: p.usb_address,
serial: p.yubikey_serial.clone(),
})
.collect();
let serials: Vec<Option<String>> = hids
.iter()
.map(|h| {
h.serial_number
.clone()
.or_else(|| keyroost_resolve::ccid_serial_for(h, &yk_readers))
})
.collect();
let mut devices: Vec<UiDevice> = Vec::new();
for p in probes.iter().filter(|p| p.is_molto2) {
let serial = p.serial.clone().unwrap_or_default();
devices.push(UiDevice {
id: format!("molto:{}", p.reader_name),
name: keyring.name_for(Some(&serial)).map(str::to_owned),
vendor: "Token2".into(),
model: "Molto2".into(),
serial,
transport: "USB · PC/SC".into(),
firmware: String::new(),
caps: {
let mut c = Caps::default();
c.insert(Caps::TOTP);
c
},
kind: DeviceKind::Token,
hid_path: None,
reader: Some(p.reader_name.clone()),
});
}
for p in probes.iter().filter(|p| !p.is_molto2) {
let mut caps = Caps::default();
if p.has_oath {
caps.insert(Caps::OATH);
}
if p.has_openpgp {
caps.insert(Caps::PGP);
}
if p.has_piv {
caps.insert(Caps::PIV);
}
if caps.is_empty() {
continue;
}
let serial = p
.yubikey_serial
.clone()
.or_else(|| p.serial.clone())
.unwrap_or_default();
let id = if serial.is_empty() {
format!("reader:{}", p.reader_name)
} else {
format!("serial:{serial}")
};
let vendor = if p.yubikey_serial.is_some() {
"Yubico".to_string()
} else {
p.reader_name
.split_whitespace()
.next()
.unwrap_or("Key")
.to_string()
};
let model = clean_model(&p.reader_name, &vendor);
devices.push(UiDevice {
id,
name: keyring.name_for(Some(&serial)).map(str::to_owned),
vendor,
model,
serial,
transport: "USB · PC/SC".into(),
firmware: String::new(),
caps,
kind: DeviceKind::Key,
hid_path: None,
reader: Some(p.reader_name.clone()),
});
}
for (i, hid) in hids.iter().enumerate() {
let serial = serials.get(i).cloned().flatten().unwrap_or_default();
let reader_name: Option<String> = if hid.vendor_id == keyroost_resolve::VID_YUBICO {
yk_readers
.iter()
.find(|c| {
c.usb_bus == hid.usb_bus
&& c.usb_address == hid.usb_address
&& c.usb_bus.is_some()
})
.or_else(|| {
let only: Vec<_> = yk_readers.iter().collect();
if only.len() == 1 {
Some(only[0])
} else {
None
}
})
.map(|c| c.reader_name.clone())
} else {
let vt = vendor_name(hid.vendor_id);
let matches: Vec<&str> = probes
.iter()
.filter(|p| !p.is_molto2)
.map(|p| p.reader_name.as_str())
.filter(|r| r.to_ascii_lowercase().contains(&vt.to_ascii_lowercase()))
.collect();
match matches.as_slice() {
[only] => Some((*only).to_string()),
_ => None,
}
};
let existing = devices.iter_mut().find(|d| {
d.kind == DeviceKind::Key
&& ((reader_name.is_some() && d.reader == reader_name)
|| (!serial.is_empty() && d.serial == serial))
});
if let Some(dev) = existing {
dev.caps.insert(Caps::FIDO2);
if hid.vendor_id == 0x349E {
dev.caps.insert(Caps::OTP);
}
dev.hid_path = Some(hid.path.clone());
dev.transport = "USB · PC/SC + FIDO HID".into();
if dev.serial.is_empty() {
dev.serial = serial.clone();
}
if dev.name.is_none() {
dev.name = keyring.name_for(Some(&serial)).map(str::to_owned);
}
} else {
let id = if !serial.is_empty() {
format!("serial:{serial}")
} else {
format!("hid:{}", hid.path.display())
};
let mut caps = Caps::default();
caps.insert(Caps::FIDO2);
if hid.vendor_id == 0x349E {
caps.insert(Caps::OTP);
}
devices.push(UiDevice {
id,
name: keyring.name_for(Some(&serial)).map(str::to_owned),
vendor: vendor_name(hid.vendor_id).to_string(),
model: clean_model(&hid.product_name, vendor_name(hid.vendor_id)),
serial,
transport: "USB · FIDO HID".into(),
firmware: String::new(),
caps,
kind: DeviceKind::Key,
hid_path: Some(hid.path.clone()),
reader: reader_name,
});
}
}
devices.sort_by(|a, b| {
(a.kind == DeviceKind::Token)
.cmp(&(b.kind == DeviceKind::Token))
.then_with(|| a.model.cmp(&b.model))
.then_with(|| a.id.cmp(&b.id))
});
Ok(devices)
}