From 297a41899374f8b373559b5c9d998e4f3c98678f Mon Sep 17 00:00:00 2001 From: micolous Date: Wed, 10 Apr 2024 13:26:41 +1000 Subject: [PATCH] YubiKey 5 vendor commands (#415) * implement yubikey config dumping * document yubikey commands, fix warnings * add yubikey to CI * ber: add X690 references, reject some edge cases (and test for that), note yubikey weirdness --- .github/workflows/ci.yml | 6 +- fido-key-manager/Cargo.toml | 1 + fido-key-manager/README.md | 20 + fido-key-manager/src/main.rs | 20 + webauthn-authenticator-rs/Cargo.toml | 2 + webauthn-authenticator-rs/src/ctap2/ctap20.rs | 5 +- webauthn-authenticator-rs/src/ctap2/mod.rs | 7 + .../src/ctap2/yubikey.rs | 37 ++ webauthn-authenticator-rs/src/lib.rs | 2 + webauthn-authenticator-rs/src/tlv/ber.rs | 310 +++++++++++ webauthn-authenticator-rs/src/tlv/mod.rs | 1 + .../src/transport/mod.rs | 2 + .../src/transport/yubikey.rs | 488 ++++++++++++++++++ webauthn-authenticator-rs/src/usb/mod.rs | 2 + webauthn-authenticator-rs/src/usb/yubikey.rs | 33 ++ 15 files changed, 933 insertions(+), 3 deletions(-) create mode 100644 webauthn-authenticator-rs/src/ctap2/yubikey.rs create mode 100644 webauthn-authenticator-rs/src/tlv/ber.rs create mode 100644 webauthn-authenticator-rs/src/tlv/mod.rs create mode 100644 webauthn-authenticator-rs/src/transport/yubikey.rs create mode 100644 webauthn-authenticator-rs/src/usb/yubikey.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 720c639d..93ef3ec8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,13 +70,15 @@ jobs: # --help should be enough to find an issue. - run: cargo run --bin cable-tunnel-server-backend -- --help - run: cargo run --bin cable-tunnel-server-frontend -- --help + - run: cargo run --bin fido-mds-tool -- --help # fido-key-manager requires elevation on Windows, which cargo can't # handle. - if: runner.os != 'windows' run: cargo run --bin fido-key-manager -- --help - if: runner.os != 'windows' run: cargo run --bin fido-key-manager --features solokey -- --help - - run: cargo run --bin fido-mds-tool -- --help + - if: runner.os != 'windows' + run: cargo run --bin fido-key-manager --features yubikey -- --help authenticator: name: webauthn-authenticator-rs test @@ -92,7 +94,7 @@ jobs: - softtoken - usb - bluetooth,nfc,usb,ctap2-management - - bluetooth,cable,cable-override-tunnel,ctap2-management,nfc,softpasskey,softtoken,usb,vendor-solokey + - bluetooth,cable,cable-override-tunnel,ctap2-management,nfc,softpasskey,softtoken,usb,vendor-solokey,vendor-yubikey os: - ubuntu-latest - windows-latest diff --git a/fido-key-manager/Cargo.toml b/fido-key-manager/Cargo.toml index 202783c3..d4d84135 100644 --- a/fido-key-manager/Cargo.toml +++ b/fido-key-manager/Cargo.toml @@ -24,6 +24,7 @@ bluetooth = ["webauthn-authenticator-rs/bluetooth"] nfc = ["webauthn-authenticator-rs/nfc"] usb = ["webauthn-authenticator-rs/usb"] solokey = ["webauthn-authenticator-rs/vendor-solokey"] +yubikey = ["webauthn-authenticator-rs/vendor-yubikey"] default = ["nfc", "usb"] diff --git a/fido-key-manager/README.md b/fido-key-manager/README.md index f259bb11..38a12280 100644 --- a/fido-key-manager/README.md +++ b/fido-key-manager/README.md @@ -115,6 +115,26 @@ Command | Description `solo-key-info` | get all connected SoloKeys' unique ID, firmware version and secure boot status `solo-key-random` | get some random bytes from a SoloKey +### YubiKey + +> **Tip:** this functionality is only available when `fido-key-manager` is built +> with `--features yubikey`. + +This only supports [YubiKey 5 series][yk5] and [Security Key by Yubico][sky] +devices via USB HID with the CTAP 2.0 interface (FIDO2) enabled. NFC support may +be added in future. + +YubiKey 4 and earlier support is not planned - they do not support CTAP 2.0, +they use a different config format and protocol, and some firmware versions +report bogus data. + +Command | Description +------- | ----------- +`yubikey-get-config` | gets a connected YubiKey's device info, firmware version and interface configuration + +[yk5]: https://www.yubico.com/products/yubikey-5-overview/ +[sky]: https://www.yubico.com/products/security-key/ + ## Platform-specific notes Bluetooth is currently disabled by default, as it's not particularly reliable on diff --git a/fido-key-manager/src/main.rs b/fido-key-manager/src/main.rs index 79bc4d1c..2416dbff 100644 --- a/fido-key-manager/src/main.rs +++ b/fido-key-manager/src/main.rs @@ -11,6 +11,8 @@ use hex::{FromHex, FromHexError}; use std::io::{stdin, stdout, Write}; use std::time::Duration; use tokio_stream::StreamExt; +#[cfg(feature = "yubikey")] +use webauthn_authenticator_rs::ctap2::YubiKeyAuthenticator; #[cfg(feature = "solokey")] use webauthn_authenticator_rs::{ctap2::SoloKeyAuthenticator, prelude::WebauthnCError}; use webauthn_authenticator_rs::{ @@ -205,6 +207,8 @@ pub enum Opt { #[cfg(feature = "solokey")] /// Gets some random bytes from a connected SoloKey 2 or Trussed device. SoloKeyRandom, + #[cfg(feature = "yubikey")] + YubikeyGetConfig, } #[derive(Debug, clap::Parser)] @@ -754,5 +758,21 @@ async fn main() { .expect("Error getting random data"); println!("Random bytes: {}", hex::encode(r)); } + + #[cfg(feature = "yubikey")] + Opt::YubikeyGetConfig => { + // TODO: filter this to just YubiKey devices in a safe way + println!("Insert a YubiKey device..."); + let mut token: CtapAuthenticator = + select_one_device(stream, &ui).await.unwrap(); + + let cfg = token + .get_yubikey_config() + .await + .expect("Error getting YubiKey config"); + + println!("YubiKey config:"); + println!("{cfg}") + } } } diff --git a/webauthn-authenticator-rs/Cargo.toml b/webauthn-authenticator-rs/Cargo.toml index 6bfb2d06..8bf651f7 100644 --- a/webauthn-authenticator-rs/Cargo.toml +++ b/webauthn-authenticator-rs/Cargo.toml @@ -52,6 +52,8 @@ ctap2 = [ ctap2-management = ["ctap2"] # Support for SoloKey's vendor commands vendor-solokey = [] +# Support for YubiKey's vendor commands +vendor-yubikey = [] nfc = ["ctap2", "dep:pcsc"] # TODO: allow running softpasskey without softtoken softpasskey = ["crypto", "softtoken"] diff --git a/webauthn-authenticator-rs/src/ctap2/ctap20.rs b/webauthn-authenticator-rs/src/ctap2/ctap20.rs index 45f45fa5..4e4cdb06 100644 --- a/webauthn-authenticator-rs/src/ctap2/ctap20.rs +++ b/webauthn-authenticator-rs/src/ctap2/ctap20.rs @@ -533,7 +533,10 @@ impl<'a, T: Token, U: UiCallback> Ctap20Authenticator<'a, T, U> { let ret = self.token.transmit(mc, self.ui_callback).await; if let Err(WebauthnCError::Ctap(e)) = ret { - if e == CtapError::Ctap2PinAuthInvalid || e == CtapError::Ctap2PinNotSet { + if e == CtapError::Ctap2PinAuthInvalid + || e == CtapError::Ctap2PinNotSet + || e == CtapError::Ctap2PinInvalid + { // User pressed the button return Ok(()); } diff --git a/webauthn-authenticator-rs/src/ctap2/mod.rs b/webauthn-authenticator-rs/src/ctap2/mod.rs index ac45ea49..6d36f4da 100644 --- a/webauthn-authenticator-rs/src/ctap2/mod.rs +++ b/webauthn-authenticator-rs/src/ctap2/mod.rs @@ -131,6 +131,9 @@ mod pin_uv; #[cfg(any(all(doc, not(doctest)), feature = "vendor-solokey"))] #[doc(hidden)] mod solokey; +#[cfg(any(all(doc, not(doctest)), feature = "vendor-yubikey"))] +#[doc(hidden)] +mod yubikey; use std::ops::{Deref, DerefMut}; use std::pin::Pin; @@ -166,6 +169,10 @@ pub use self::{ #[doc(inline)] pub use self::solokey::SoloKeyAuthenticator; +#[cfg(any(all(doc, not(doctest)), feature = "vendor-yubikey"))] +#[doc(inline)] +pub use self::yubikey::YubiKeyAuthenticator; + /// Abstraction for different versions of the CTAP2 protocol. /// /// All tokens can [Deref] into [Ctap20Authenticator]. diff --git a/webauthn-authenticator-rs/src/ctap2/yubikey.rs b/webauthn-authenticator-rs/src/ctap2/yubikey.rs new file mode 100644 index 00000000..535dca61 --- /dev/null +++ b/webauthn-authenticator-rs/src/ctap2/yubikey.rs @@ -0,0 +1,37 @@ +use async_trait::async_trait; + +use crate::{ + prelude::WebauthnCError, + transport::{ + yubikey::{YubiKeyConfig, YubiKeyToken}, + Token, + }, + ui::UiCallback, +}; + +use super::Ctap20Authenticator; + +/// YubiKey vendor-specific commands. +/// +/// ## Warning +/// +/// These commands currently operate on *any* [`Ctap20Authenticator`][], and do +/// not filter to just YubiKey devices. Due to the nature of CTAP +/// vendor-specific commands, this may cause unexpected or undesirable behaviour +/// on other vendors' keys. +/// +/// Protocol notes are in [`crate::transport::yubikey`]. +#[async_trait] +pub trait YubiKeyAuthenticator { + async fn get_yubikey_config(&mut self) -> Result; +} + +#[async_trait] +impl<'a, T: Token + YubiKeyToken, U: UiCallback> YubiKeyAuthenticator + for Ctap20Authenticator<'a, T, U> +{ + #[inline] + async fn get_yubikey_config(&mut self) -> Result { + self.token.get_yubikey_config().await + } +} diff --git a/webauthn-authenticator-rs/src/lib.rs b/webauthn-authenticator-rs/src/lib.rs index 5e109eaf..51c53d9c 100644 --- a/webauthn-authenticator-rs/src/lib.rs +++ b/webauthn-authenticator-rs/src/lib.rs @@ -129,6 +129,8 @@ mod crypto; #[cfg(any(all(doc, not(doctest)), feature = "ctap2"))] pub mod ctap2; pub mod error; +#[cfg(any(all(doc, not(doctest)), feature = "vendor-yubikey"))] +mod tlv; #[cfg(any(all(doc, not(doctest)), feature = "ctap2"))] pub mod transport; pub mod types; diff --git a/webauthn-authenticator-rs/src/tlv/ber.rs b/webauthn-authenticator-rs/src/tlv/ber.rs new file mode 100644 index 00000000..e55d5b2a --- /dev/null +++ b/webauthn-authenticator-rs/src/tlv/ber.rs @@ -0,0 +1,310 @@ +//! [BerTlvParser] is an [Iterator]-based BER-TLV parser. +//! +//! This implements a subset of [the BER-TLV specification][0] to handle +//! YubiKey's non-conformant configuration format. +//! +//! [0]: https://www.itu.int/rec/T-REC-X.690-202102-I/en + +/// An [Iterator]-based BER-TLV parser. +pub(crate) struct BerTlvParser<'a> { + b: &'a [u8], +} + +impl BerTlvParser<'_> { + /// Parses a BER-TLV structure in the given slice. + pub fn new(tlv: &[u8]) -> BerTlvParser { + // Skip null bytes at the start + let mut i = 0; + while i < tlv.len() && tlv[i] == 0 { + i += 1; + } + + BerTlvParser { b: &tlv[i..] } + } + + /// Sets the internal buffer to an empty range, effectively "bricking" the + /// iterator. + #[inline] + fn brick(&mut self) { + self.b = &self.b[0..0]; + } + + /// Returns `None` if the internal buffer is empty. + fn stop_if_empty(&self) -> Option<()> { + if self.b.is_empty() { + None + } else { + Some(()) + } + } + + /// Returns `None` and [bricks][0] the buffer if it contains less than + /// `bytes` bytes. + /// + /// [0]: BerTlvParser::brick + fn stop_and_brick_if_less_than(&mut self, bytes: usize) -> Option<()> { + if self.b.len() < bytes { + error!("bricked: less than {bytes} bytes: {}", self.b.len()); + self.brick(); + None + } else { + Some(()) + } + } +} + +impl<'a> Iterator for BerTlvParser<'a> { + /// A BER-TLV item, a tuple of `class, constructed, tag, value`. + type Item = (u8, bool, u16, &'a [u8]); + + fn next(&mut self) -> Option { + self.stop_if_empty()?; + + // ISO/IEC 7816-4:2005 §5.2.2.1: BER-TLV tag fields + // Rec. ITU-T X.690 §8.1.2: BER identifier octets + let class = self.b[0] >> 6; + let constructed = self.b[0] & 0x20 != 0; + let mut tag = u16::from(self.b[0] & 0x1f); + self.b = &self.b[1..]; + + if tag == 0x1f { + self.stop_if_empty()?; + // 0b???1_1111 = 2 or 3 byte tag value + tag = u16::from(self.b[0]); + if tag < 0x1f { + // Rec. ITU-T X.690 §8.1.2.3 + error!("low tag number ({tag}) incorrectly encoded in high tag number form"); + self.brick(); + return None; + } + + if tag == 0x80 { + // Rec. ITU-T X.690 §8.1.2.4.2 (c) + error!("high tag number incorrectly encoded with padding"); + self.brick(); + return None; + } + + self.b = &self.b[1..]; + + // 2 byte tag value as 0b0???_???? (31..=127) + // 3 byte tag value as 0b1???_???? 0b0???_???? (128..=16383) + if tag & 0x80 == 0x80 { + self.stop_if_empty()?; + + if self.b[0] & 0x80 != 0 { + error!("tag values longer than 3 bytes not supported"); + self.brick(); + return None; + } + tag = (tag & 0x7f) << 7; + tag |= u16::from(self.b[0]) & 0x7f; + self.b = &self.b[1..]; + } + } + + // ISO/IEC 7816-4:2005 §5.2.2.2 BER-TLV length fields + // Rec. ITU-T X.690 §8.1.3: BER length octets + self.stop_if_empty()?; + let len = self.b[0]; + self.b = &self.b[1..]; + + let len = match len { + 0..=0x7f => u32::from(len), + + 0x80 => { + error!("indefinite length not supported"); + self.brick(); + return None; + } + + 0x81 => { + self.stop_if_empty()?; + let len = u32::from(self.b[0]); + self.b = &self.b[1..]; + len + } + + 0x82 => { + self.stop_and_brick_if_less_than(2); + let len = u32::from(u16::from_be_bytes(self.b[..2].try_into().ok()?)); + self.b = &self.b[2..]; + len + } + + 0x83 => { + self.stop_and_brick_if_less_than(3)?; + let mut buf = [0; 4]; + buf[1..].copy_from_slice(&self.b[..3]); + let len = u32::from_be_bytes(buf); + self.b = &self.b[3..]; + len + } + + 0x84 => { + self.stop_and_brick_if_less_than(4)?; + let len = u32::from_be_bytes(self.b[..4].try_into().ok()?); + self.b = &self.b[4..]; + len + } + + 0x85..=0xff => { + error!("invalid BER-TLV length field length: {len:#x}"); + self.brick(); + return None; + } + }; + + let len = len as usize; + self.stop_and_brick_if_less_than(len)?; + let v = &self.b[..len]; + self.b = &self.b[len..]; + Some((class, constructed, tag, v)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_tlv_parser(expected: &[(u8, u16, &[u8])], p: BerTlvParser<'_>) { + let mut i = 0; + for (actual_cls, actual_constructed, actual_tag, actual_val) in p { + let (expected_cls, expected_tag, expected_val) = expected[i]; + assert_eq!(expected_cls, actual_cls, "cls mismatch at {i}"); + assert!(!actual_constructed, "unexpected constructed value at {i}"); + assert_eq!(expected_tag, actual_tag, "tag mismatch at {i}"); + assert_eq!( + expected_val, actual_val, + "val mismatch at {i} (tag={expected_tag})" + ); + i += 1; + } + assert_eq!(expected.len(), i); + } + + #[test] + fn yubico_security_key_c_nfc() { + let _ = tracing_subscriber::fmt().try_init(); + + let v = hex::decode(concat!( + "28", // length byte, not TLV + "0102", // cls=0, tag=1, len=2 + "0202", // + "0302", // cls=0, tag=3, len=2 + "0200", // + "0401", // cls=0, tag=4, len=1 + "43", // + "0503", // cls=0, tag=5, len=3 + "050403", // + "0602", // cls=0, tag=6, len=2 + "0000", // + "0701", // cls=0, tag=7, len=1 + "0f", // + "0801", // cls=0, tag=8, len=1 + "00", // + "0d02", // cls=0, tag=13, len=2 + "0206", // + "0e02", // cls=0, tag=14, len=2 + "0200", // + "0a01", // cls=0, tag=10, len=1 + "00", // + "0f01", // cls=0, tag=15, len=1 + "00", // + )) + .unwrap(); + let expected: [(u8, u16, &[u8]); 11] = [ + (0, 1, b"\x02\x02"), + (0, 3, b"\x02\0"), + (0, 4, b"\x43"), + (0, 5, b"\x05\x04\x03"), + (0, 6, b"\0\0"), + (0, 7, b"\x0f"), + (0, 8, b"\0"), + (0, 13, b"\x02\x06"), + (0, 14, b"\x02\0"), + (0, 10, b"\0"), + (0, 15, b"\0"), + ]; + + let p = BerTlvParser::new(&v[1..]); + assert_tlv_parser(expected.as_slice(), p); + } + + #[test] + fn yubikey_5c() { + let _ = tracing_subscriber::fmt().try_init(); + + let v = hex::decode(concat!( + "23", // length byte, not TLV + "0102", // cls=0, tag=1, len=2 + "023f", // + "0302", // cls=0, tag=3, len=2 + "0218", // + "0204", // cls=0, tag=2, len=4 + "cafe1234", // + "0401", // cls=0, tag=4, len=1 + "03", // + "0503", // cls=0, tag=5, len=3 + "050102", // + "0602", // cls=0, tag=6, len=2 + "0000", // + "0701", // cls=0, tag=7, len=1 + "0f", // + "0801", // cls=0, tag=8, len=1 + "00", // + "0a01", // cls=0, tag=10, len=1 + "00", // + )) + .unwrap(); + let expected: [(u8, u16, &[u8]); 9] = [ + (0, 1, b"\x02\x3f"), + (0, 3, b"\x02\x18"), + (0, 2, b"\xca\xfe\x12\x34"), + (0, 4, b"\x03"), + (0, 5, b"\x05\x01\x02"), + (0, 6, b"\0\0"), + (0, 7, b"\x0f"), + (0, 8, b"\0"), + (0, 10, b"\0"), + ]; + + let p = BerTlvParser::new(&v[1..]); + assert_tlv_parser(expected.as_slice(), p); + } + + #[test] + fn low_tag_number_as_high() { + let b = hex::decode("9f01020000").unwrap(); + let mut p = BerTlvParser::new(&b); + assert!(p.next().is_none()); + } + + #[test] + fn high_tag_number_padded() { + let b = hex::decode("9f8001020000").unwrap(); + let mut p = BerTlvParser::new(&b); + assert!(p.next().is_none()); + } + + #[test] + fn huge_tag_number_not_supported() { + let b = hex::decode("9f8f8f0f020000").unwrap(); + let mut p = BerTlvParser::new(&b); + assert!(p.next().is_none()); + } + + #[test] + fn indefinite_form_not_supported() { + let b = hex::decode("018012340000").unwrap(); + let mut p = BerTlvParser::new(&b); + assert!(p.next().is_none()); + } + + #[test] + fn really_long_length_not_supported() { + let b = hex::decode("018500000000020000").unwrap(); + let mut p = BerTlvParser::new(&b); + assert!(p.next().is_none()); + } +} diff --git a/webauthn-authenticator-rs/src/tlv/mod.rs b/webauthn-authenticator-rs/src/tlv/mod.rs new file mode 100644 index 00000000..1458eddc --- /dev/null +++ b/webauthn-authenticator-rs/src/tlv/mod.rs @@ -0,0 +1 @@ +pub mod ber; diff --git a/webauthn-authenticator-rs/src/transport/mod.rs b/webauthn-authenticator-rs/src/transport/mod.rs index c1d8a3d4..c6d4d268 100644 --- a/webauthn-authenticator-rs/src/transport/mod.rs +++ b/webauthn-authenticator-rs/src/transport/mod.rs @@ -7,6 +7,8 @@ pub mod iso7816; pub(crate) mod solokey; #[cfg(any(doc, feature = "bluetooth", feature = "usb"))] pub(crate) mod types; +#[cfg(any(all(doc, not(doctest)), feature = "vendor-yubikey"))] +pub(crate) mod yubikey; pub use crate::transport::any::{AnyToken, AnyTransport}; diff --git a/webauthn-authenticator-rs/src/transport/yubikey.rs b/webauthn-authenticator-rs/src/transport/yubikey.rs new file mode 100644 index 00000000..0d63deb6 --- /dev/null +++ b/webauthn-authenticator-rs/src/transport/yubikey.rs @@ -0,0 +1,488 @@ +//! YubiKey vendor-specific commands. +//! +//! This currently only supports YubiKey 5 and later. Older keys have different +//! config formats and protocols, some firmwares give bogus data. +//! +//! ## USB HID +//! +//! Commands are sent on a `U2FHIDFrame` level, and values are bitwise-OR'd +//! with `transport::TYPE_INIT` (0x80). +//! +//! Command | Description | Request | Response +//! ------- | ----------- | ------- | -------- +//! `0x40` | Set legacy device config | ... | ... +//! `0x42` | Get device config | _none_ | [`YubiKeyConfig`] +//! `0x43` | Set device config | [`YubiKeyConfig`] | none? +//! +//! ## NFC +//! +//! **NFC support is not yet implemented.** +//! +//! Management app AID: `a000000527471117` +//! +//! All commands sent with CLA = `0x00`, P2 = `0x00`. +//! +//! INS | P1 | Description | Request | Response +//! ------ | ------ | ----------- | ------- | -------- +//! `0x16` | `0x11` | Set legacy device config | ... | ... +//! `0x1D` | `0x00` | Get device config | _none_ | [`YubiKeyConfig`] +//! `0x1C` | `0x00` | Set device config | [`YubiKeyConfig`] | none? +//! +//! ## References +//! +//! * [DeviceInfo structure][0] (includes config) +//! +//! [0]: https://github.com/Yubico/yubikey-manager/blob/51a7ae438c923189788a1e31d3de18d452131942/yubikit/management.py#L223 +use async_trait::async_trait; +use bitflags::bitflags; +use num_traits::cast::FromPrimitive; + +use crate::{prelude::WebauthnCError, tlv::ber::BerTlvParser}; + +use super::AnyToken; + +#[cfg(all(feature = "usb", feature = "vendor-yubikey"))] +pub(crate) const CMD_GET_CONFIG: u8 = super::TYPE_INIT | 0x42; + +bitflags! { + /// Bitmask of enabled / available interfaces. + /// + /// `ykman` calls these "Capabilities". + /// + /// Reference: + #[derive(Default)] + pub struct Interface: u16 { + const OTP = 0x01; + const CTAP1 = 0x02; + const OPENPGP = 0x08; + const PIV = 0x10; + const OATH = 0x20; + const YUBIHSM = 0x100; + const CTAP2 = 0x200; + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, FromPrimitive, ToPrimitive)] +#[repr(u16)] +enum ConfigKey { + #[default] + Unknown = 0x0, + SupportedUsbInterfaces = 0x1, + Serial = 0x2, + EnabledUsbInterfaces = 0x3, + FormFactor = 0x4, + Version = 0x5, + AutoEjectTimeout = 0x6, + ChallengeResponseTimeout = 0x7, + DeviceFlags = 0x8, + AppVersions = 0x9, + /// 16 bytes lock code, or indicates when a device is locked + ConfigLock = 0xa, + /// 16 bytes unlock code, to unlock a locked device + Unlock = 0xb, + Reboot = 0xc, + SupportedNfcInterfaces = 0xd, + EnabledNfcInterfaces = 0xe, +} + +/// YubiKey device form factor. +/// +/// Only the lower 3 bits of the `u8` are used. +#[derive(Debug, Clone, PartialEq, Eq, Default, FromPrimitive, ToPrimitive)] +#[repr(u8)] +pub enum FormFactor { + #[default] + Unknown = 0x0, + /// USB-A keychain-size device + UsbAKeychain = 0x1, + /// USB-A nano-size device + UsbANano = 0x2, + /// USB-C keychain-size device + UsbCKeychain = 0x3, + /// USB-C nano-size device + UsbCNano = 0x4, + /// USB-C + Lightning device + UsbCLightning = 0x5, + /// USB-A + biometric device + UsbABio = 0x6, + /// USB-C + biometric device + UsbCBio = 0x7, +} + +/// YubiKey device info / configuration structure +/// +/// ## Payload format +/// +/// * `u8`: length +/// * BER-TLV-like payload +/// +/// The payload is BER-TLV-like, with some differences: +/// +/// * all tags use the universal class (0x00) +/// * tag numbers are one of the values in [`ConfigKey`] +/// * values are encoded directly +#[derive(Debug, Default, PartialEq, Eq)] +pub struct YubiKeyConfig { + /// Device serial number. This isn't available on all devices. + pub serial: Option, + /// Form factor of the device. + pub form_factor: FormFactor, + /// Firmware version of the device. + pub version: [u8; 3], + /// `true` if a configuration lock has been set on the device. + pub is_locked: bool, + /// `true` if the device is FIPS-certified. + pub is_fips: bool, + /// `true` if the device is a "Security Key" (CTAP-only), `false` if it is a + /// "YubiKey". + pub is_security_key: bool, + pub supports_remote_wakeup: bool, + pub supports_eject: bool, + /// Interfaces which are supported over USB. + pub supported_usb_interfaces: Interface, + /// Interfaces which are enabled over USB. + pub enabled_usb_interfaces: Interface, + /// Interfaces which are supported over NFC. Non-NFC devices don't set any + /// values here. + pub supported_nfc_interfaces: Interface, + /// Interfaces which are enabled over NFC. + pub enabled_nfc_interfaces: Interface, + pub auto_eject_timeout: u16, + pub challenge_response_timeout: u16, +} + +impl YubiKeyConfig { + pub fn from_bytes(b: &[u8]) -> Result { + if b.is_empty() { + return Err(WebauthnCError::InvalidMessageLength); + } + let len = b[0]; + if b.len() - 1 != usize::from(len) { + return Err(WebauthnCError::InvalidMessageLength); + } + + let mut o = YubiKeyConfig { + ..Default::default() + }; + let parser = BerTlvParser::new(&b[1..]); + + for (cls, constructed, tag, val) in parser { + if cls != 0 || constructed { + continue; + } + let Some(key) = ConfigKey::from_u16(tag) else { + continue; + }; + + match key { + ConfigKey::Unknown => continue, + ConfigKey::SupportedUsbInterfaces => { + if val.len() != 2 { + continue; + } + let v = u16::from_be_bytes( + val.try_into() + .map_err(|_| WebauthnCError::InvalidMessageLength)?, + ); + if let Some(i) = Interface::from_bits(v & Interface::all().bits()) { + o.supported_usb_interfaces = i; + } + } + ConfigKey::Serial => { + if val.len() != 4 { + continue; + } + o.serial = val.try_into().map(u32::from_be_bytes).ok(); + } + ConfigKey::EnabledUsbInterfaces => { + if val.len() != 2 { + continue; + } + let v = u16::from_be_bytes( + val.try_into() + .map_err(|_| WebauthnCError::InvalidMessageLength)?, + ); + if let Some(i) = Interface::from_bits(v & Interface::all().bits()) { + o.enabled_usb_interfaces = i; + } + } + ConfigKey::FormFactor => { + if val.is_empty() { + continue; + } + if let Some(f) = FormFactor::from_u8(val[0] & 0x7) { + o.form_factor = f; + } + o.is_fips = val[0] & 0x80 != 0; + o.is_security_key = val[0] & 0x40 != 0; + } + ConfigKey::Version => { + if let Ok(v) = val.try_into() { + o.version = v; + } + } + ConfigKey::AutoEjectTimeout => { + if let Some(v) = variable_be_bytes_to_u16(val) { + o.auto_eject_timeout = v; + } + } + ConfigKey::ChallengeResponseTimeout => { + if let Some(v) = variable_be_bytes_to_u16(val) { + o.challenge_response_timeout = v; + } + } + ConfigKey::DeviceFlags => { + if val.is_empty() { + continue; + } + o.supports_remote_wakeup = val[0] & 0x40 != 0; + o.supports_eject = val[0] & 0x80 != 0; + } + ConfigKey::AppVersions => { + continue; + } + ConfigKey::ConfigLock => { + if val.is_empty() { + continue; + } + o.is_locked = val[0] == 1; + } + ConfigKey::Unlock => continue, + ConfigKey::Reboot => continue, + ConfigKey::SupportedNfcInterfaces => { + if val.len() != 2 { + continue; + } + let v = u16::from_be_bytes( + val.try_into() + .map_err(|_| WebauthnCError::InvalidMessageLength)?, + ); + if let Some(i) = Interface::from_bits(v & Interface::all().bits()) { + o.supported_nfc_interfaces = i; + } + } + ConfigKey::EnabledNfcInterfaces => { + if val.len() != 2 { + continue; + } + let v = u16::from_be_bytes( + val.try_into() + .map_err(|_| WebauthnCError::InvalidMessageLength)?, + ); + if let Some(i) = Interface::from_bits(v & Interface::all().bits()) { + o.enabled_nfc_interfaces = i; + } + } + } + } + + Ok(o) + } + + pub fn is_preview(&self) -> bool { + match (self.version[0], self.version[1], self.version[2]) { + (5, 0, _) => true, + (5, 2, z) => z < 3, + (5, 5, z) => z < 2, + _ => false, + } + } +} + +impl std::fmt::Display for YubiKeyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "Form factor: {:?}{}", + self.form_factor, + if self.is_security_key { + " Security Key" + } else { + " YubiKey" + } + )?; + writeln!( + f, + "Version: {}.{}.{}", + self.version[0], self.version[1], self.version[2] + )?; + + write!(f, "Flags: ")?; + if self.is_preview() { + write!(f, "preview, ")?; + } + if self.is_locked { + write!(f, "config locked, ")?; + } + if self.is_fips { + write!(f, "FIPS, ")?; + } + if self.supports_remote_wakeup { + write!(f, "remote wake-up, ")?; + } + if self.supports_eject { + write!(f, "eject, ")?; + } + writeln!(f)?; + + if let Some(serial) = self.serial { + writeln!(f, "Serial: {serial}")?; + } + + if !self.supported_usb_interfaces.is_empty() { + writeln!( + f, + "Supported USB interfaces: {:?}", + self.supported_usb_interfaces + )?; + writeln!( + f, + "Enabled USB interfaces: {:?}", + self.enabled_usb_interfaces + )?; + } + + if !self.supported_nfc_interfaces.is_empty() { + writeln!( + f, + "Supported NFC interfaces: {:?}", + self.supported_nfc_interfaces + )?; + writeln!( + f, + "Enabled NFC interfaces: {:?}", + self.enabled_nfc_interfaces + )?; + } + + if self.auto_eject_timeout != 0 { + writeln!(f, "Auto-eject timeout: {}", self.auto_eject_timeout)?; + } + + if self.challenge_response_timeout != 0 { + writeln!( + f, + "Challenge-response timeout: {}", + self.challenge_response_timeout + )?; + } + + Ok(()) + } +} + +/// See [`YubiKeyAuthenticator`](crate::ctap2::YubiKeyAuthenticator). +#[async_trait] +pub trait YubiKeyToken { + /// See [`SoloKeyAuthenticator::get_solokey_lock()`](crate::ctap2::SoloKeyAuthenticator::get_solokey_lock). + async fn get_yubikey_config(&mut self) -> Result; +} + +#[async_trait] +#[allow(clippy::unimplemented)] +impl YubiKeyToken for AnyToken { + async fn get_yubikey_config(&mut self) -> Result { + match self { + AnyToken::Stub => unimplemented!(), + #[cfg(feature = "bluetooth")] + AnyToken::Bluetooth(_) => Err(WebauthnCError::NotSupported), + #[cfg(feature = "nfc")] + AnyToken::Nfc(_) => Err(WebauthnCError::NotSupported), + #[cfg(feature = "usb")] + AnyToken::Usb(u) => u.get_yubikey_config().await, + } + } +} + +fn variable_be_bytes_to_u16(b: &[u8]) -> Option { + if b.len() == 1 { + Some(u16::from(b[0])) + } else if b.len() == 2 { + b.try_into().map(u16::from_be_bytes).ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yubikey_5c() { + let _ = tracing_subscriber::fmt().try_init(); + let expected = YubiKeyConfig { + serial: Some(0xcafe1234), + form_factor: FormFactor::UsbCKeychain, + version: [5, 1, 2], + supported_usb_interfaces: Interface::OTP + | Interface::CTAP1 + | Interface::CTAP2 + | Interface::OPENPGP + | Interface::PIV + | Interface::OATH, + enabled_usb_interfaces: Interface::OPENPGP | Interface::PIV | Interface::CTAP2, + supported_nfc_interfaces: Interface::empty(), + enabled_nfc_interfaces: Interface::empty(), + auto_eject_timeout: 0, + challenge_response_timeout: 15, + ..Default::default() + }; + let v = + hex::decode("230102023f030202180204cafe123404010305030501020602000007010f0801000a0100") + .unwrap(); + let cfg = YubiKeyConfig::from_bytes(v.as_slice()).unwrap(); + assert_eq!(expected, cfg); + } + + #[test] + fn yubikey_5c_nano() { + let _ = tracing_subscriber::fmt().try_init(); + let expected = YubiKeyConfig { + serial: Some(0xcafe1234), + form_factor: FormFactor::UsbCNano, + version: [5, 2, 4], + supported_usb_interfaces: Interface::OTP + | Interface::CTAP1 + | Interface::CTAP2 + | Interface::OPENPGP + | Interface::PIV + | Interface::OATH, + enabled_usb_interfaces: Interface::CTAP1 | Interface::CTAP2, + supported_nfc_interfaces: Interface::empty(), + enabled_nfc_interfaces: Interface::empty(), + auto_eject_timeout: 0, + challenge_response_timeout: 15, + ..Default::default() + }; + let v = hex::decode( + "260102023f030202020204cafe123404010405030502040602000007010f0801000a01000f0100", + ) + .unwrap(); + let cfg = YubiKeyConfig::from_bytes(v.as_slice()).unwrap(); + assert_eq!(expected, cfg); + } + + #[test] + fn yubico_security_key_c_nfc() { + let _ = tracing_subscriber::fmt().try_init(); + let expected = YubiKeyConfig { + version: [5, 4, 3], + form_factor: FormFactor::UsbCKeychain, + is_security_key: true, + supported_usb_interfaces: Interface::CTAP1 | Interface::CTAP2, + enabled_usb_interfaces: Interface::CTAP2, + supported_nfc_interfaces: Interface::CTAP1 | Interface::CTAP2, + enabled_nfc_interfaces: Interface::CTAP2, + challenge_response_timeout: 15, + ..Default::default() + }; + + let v = hex::decode( + "28010202020302020004014305030504030602000007010f0801000d0202060e0202000a01000f0100", + ) + .unwrap(); + let cfg = YubiKeyConfig::from_bytes(v.as_slice()).unwrap(); + assert_eq!(expected, cfg); + } +} diff --git a/webauthn-authenticator-rs/src/usb/mod.rs b/webauthn-authenticator-rs/src/usb/mod.rs index 8fdf2388..63b202b4 100644 --- a/webauthn-authenticator-rs/src/usb/mod.rs +++ b/webauthn-authenticator-rs/src/usb/mod.rs @@ -15,6 +15,8 @@ mod framing; mod responses; #[cfg(any(all(doc, not(doctest)), feature = "vendor-solokey"))] mod solokey; +#[cfg(any(all(doc, not(doctest)), feature = "vendor-yubikey"))] +mod yubikey; use fido_hid_rs::{ HidReportBytes, HidSendReportBytes, USBDevice, USBDeviceImpl, USBDeviceInfo, USBDeviceInfoImpl, diff --git a/webauthn-authenticator-rs/src/usb/yubikey.rs b/webauthn-authenticator-rs/src/usb/yubikey.rs new file mode 100644 index 00000000..3f4b89ff --- /dev/null +++ b/webauthn-authenticator-rs/src/usb/yubikey.rs @@ -0,0 +1,33 @@ +use async_trait::async_trait; + +#[cfg(all(feature = "usb", feature = "vendor-yubikey"))] +use crate::transport::yubikey::CMD_GET_CONFIG; + +use crate::{ + prelude::WebauthnCError, + transport::{ + types::{U2FError, U2FHID_ERROR}, + yubikey::{YubiKeyConfig, YubiKeyToken}, + }, + usb::{framing::U2FHIDFrame, USBToken}, +}; + +#[async_trait] +impl YubiKeyToken for USBToken { + async fn get_yubikey_config(&mut self) -> Result { + let cmd = U2FHIDFrame { + cid: self.cid, + cmd: CMD_GET_CONFIG, + len: 0, + data: vec![], + }; + self.send_one(&cmd).await?; + + let r = self.recv_one().await?; + match r.cmd { + CMD_GET_CONFIG => YubiKeyConfig::from_bytes(r.data.as_slice()), + U2FHID_ERROR => Err(U2FError::from(r.data.as_slice()).into()), + _ => Err(WebauthnCError::UnexpectedState), + } + } +}