diff --git a/build.rs b/build.rs index 2e1290d..f322fbe 100644 --- a/build.rs +++ b/build.rs @@ -1,8 +1,13 @@ +//! Build script for embedding application resources. +//! +//! On Windows, embeds the application icon into the PE binary so that +//! the `.exe` and taskbar show the correct icon. On Unix this is a no-op. + #[cfg(windows)] #[allow(clippy::single_component_path_imports)] use tauri_winres; -// Configures windows application resource.( fix for app icon and launching app as admin) +/// Embed the application icon into the Windows PE binary. #[cfg(windows)] fn main() { let mut res = tauri_winres::WindowsResource::new(); diff --git a/src/error.rs b/src/error.rs index bc499fc..369bc5a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,12 +1,23 @@ -/// Custom error types for Pico Forge application. +//! Application-wide error types. +//! +//! `PFError` is a single enum covering the four failure modes +//! encountered during device discovery, communication, and I/O. +//! Each variant carries enough context to render a user-facing message +//! and to serialize through the UI layer. + +/// Custom error types for PicoForge operations. #[derive(Debug, thiserror::Error)] pub enum PFError { + /// No compatible FIDO device could be detected on any transport. #[error("No device found")] NoDevice, + /// Wrapped error from the PC/SC smart card subsystem. #[error("PCSC Error: {0}")] Pcsc(#[from] pcsc::Error), + /// An I/O or encoding/decoding failure (hex, CBOR, transport framing). #[error("IO/Hex Error: {0}")] Io(String), + /// A device-level error returned by the firmware or transport layer. #[error("Device Error: {0}")] Device(String), } diff --git a/src/hal/common/cose.rs b/src/hal/common/cose.rs new file mode 100644 index 0000000..fef6b64 --- /dev/null +++ b/src/hal/common/cose.rs @@ -0,0 +1,161 @@ +//! COSE (CBOR Object Signing and Encryption) algorithm, curve, and key-parameter +//! constants used in CTAP2 credential creation and authentication responses. + +#![allow(dead_code)] + +use std::fmt; + +/// COSE algorithm identifiers as defined in the IANA COSE Algorithms registry. +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoseAlgorithm { + /// ECDSA w/ SHA-256 on P-256 (NIST P-256 / secp256r1). + ES256 = -7, + /// EdDSA (Edwards-curve Digital Signature Algorithm). + EdDSA = -8, + /// ECDSA w/ SHA-256 on P-256 (parallel-sphere variant). + ESP256 = -9, + /// Ed25519 signature algorithm (EdDSA on Curve25519). + Ed25519 = -19, + /// ECDH-ES key agreement w/ HKDF-256. + EcdhEsHkdf256 = -25, + /// ECDSA w/ SHA-384 on P-384. + ES384 = -35, + /// ECDSA w/ SHA-512 on P-521. + ES512 = -36, + /// ECDSA w/ SHA-256 on secp256k1 (Koblitz curve). + ES256K = -47, + /// ECDSA w/ SHA-384 on P-384 (parallel-sphere variant). + ESP384 = -51, + /// ECDSA w/ SHA-512 on P-521 (parallel-sphere variant). + ESP512 = -52, + /// Ed448 signature algorithm. + Ed448 = -53, + /// RSASSA-PKCS1-v1_5 w/ SHA-256. + RS256 = -257, + /// RSASSA-PKCS1-v1_5 w/ SHA-384. + RS384 = -258, + /// RSASSA-PKCS1-v1_5 w/ SHA-512. + RS512 = -259, + /// BLS (Boneh–Lynn–Shacham) signature w/ BLS12-381 (curve B). + ESB256 = -265, + /// BLS signature w/ BLS12-381 (curve B, larger subgroup). + ESB384 = -267, + /// BLS signature w/ BLS12-381 (curve B, full size). + ESB512 = -268, + /// ML-DSA-44 (CRYSTALS-Dilithium, NIST Level 2). + MLDSA44 = -48, + /// ML-DSA-65 (CRYSTALS-Dilithium, NIST Level 3). + MLDSA65 = -49, + /// ML-DSA-87 (CRYSTALS-Dilithium, NIST Level 5). + MLDSA87 = -50, +} + +impl CoseAlgorithm { + /// Decode a COSE algorithm identifier from an `i128` value as seen in + /// CTAP2 `authenticatorGetInfo` or credential public-key data. + pub fn from_i128(val: i128) -> Option { + match val as i32 { + -7 => Some(Self::ES256), + -8 => Some(Self::EdDSA), + -9 => Some(Self::ESP256), + -19 => Some(Self::Ed25519), + -25 => Some(Self::EcdhEsHkdf256), + -35 => Some(Self::ES384), + -36 => Some(Self::ES512), + -47 => Some(Self::ES256K), + -51 => Some(Self::ESP384), + -52 => Some(Self::ESP512), + -53 => Some(Self::Ed448), + -257 => Some(Self::RS256), + -258 => Some(Self::RS384), + -259 => Some(Self::RS512), + -265 => Some(Self::ESB256), + -267 => Some(Self::ESB384), + -268 => Some(Self::ESB512), + -48 => Some(Self::MLDSA44), + -49 => Some(Self::MLDSA65), + -50 => Some(Self::MLDSA87), + _ => None, + } + } +} + +impl fmt::Display for CoseAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ES256 => write!(f, "ES256"), + Self::EdDSA => write!(f, "EdDSA"), + Self::ESP256 => write!(f, "ESP256"), + Self::Ed25519 => write!(f, "Ed25519"), + Self::EcdhEsHkdf256 => write!(f, "ECDH-ES-HKDF-256"), + Self::ES384 => write!(f, "ES384"), + Self::ES512 => write!(f, "ES512"), + Self::ES256K => write!(f, "ES256K"), + Self::ESP384 => write!(f, "ESP384"), + Self::ESP512 => write!(f, "ESP512"), + Self::Ed448 => write!(f, "Ed448"), + Self::RS256 => write!(f, "RS256"), + Self::RS384 => write!(f, "RS384"), + Self::RS512 => write!(f, "RS512"), + Self::ESB256 => write!(f, "ESB256"), + Self::ESB384 => write!(f, "ESB384"), + Self::ESB512 => write!(f, "ESB512"), + Self::MLDSA44 => write!(f, "ML-DSA-44"), + Self::MLDSA65 => write!(f, "ML-DSA-65"), + Self::MLDSA87 => write!(f, "ML-DSA-87"), + } + } +} + +/// COSE elliptic curve identifiers from the IANA COSE Elliptic Curves registry. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoseCurve { + /// NIST P-256 (secp256r1). + P256 = 1, + /// NIST P-384 (secp384r1). + P384 = 2, + /// NIST P-521 (secp521r1). + P521 = 3, + /// X25519 key-exchange curve. + X25519 = 4, + /// X448 key-exchange curve. + X448 = 5, + /// Ed25519 signing curve. + Ed25519 = 6, + /// Ed448 signing curve. + Ed448 = 7, + /// secp256k1 (Koblitz curve, used by ES256K). + P256K1 = 8, + /// Barreto–Naehrig BN256 curve (pairing-friendly). + BP256R1 = 9, + /// Barreto–Naehrig BN384 curve (pairing-friendly). + BP384R1 = 10, + /// Barreto–Naehrig BN512 curve (pairing-friendly). + BP512R1 = 11, +} + +/// COSE key-parameter labels from RFC 8152 §7.1 / IANA. +#[repr(i32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoseKeyParam { + /// Key type (kty). + Kty = 1, + /// Key identifier (kid). + Kid = 2, + /// Algorithm (alg). + Alg = 3, + /// Key operations (key_ops). + KeyOps = 4, + /// Base initialization vector (Base IV). + BaseIV = 5, + /// Curve / subgroup (crv). + Crv = -1, + /// X coordinate. + X = -2, + /// Y coordinate. + Y = -3, + /// Private key (d). + D = -4, +} diff --git a/src/hal/common/mod.rs b/src/hal/common/mod.rs new file mode 100644 index 0000000..350507a --- /dev/null +++ b/src/hal/common/mod.rs @@ -0,0 +1,6 @@ +//! Shared COSE algorithm/curve/key-parameter definitions and firmware-version parsing. + +pub mod cose; +pub mod version; + +pub use version::FirmwareVersion; diff --git a/src/hal/common/version.rs b/src/hal/common/version.rs new file mode 100644 index 0000000..4e26aa4 --- /dev/null +++ b/src/hal/common/version.rs @@ -0,0 +1,197 @@ +//! Firmware version parsing and comparison helpers. +//! +//! Firmware version strings follow a `major.minor[.patch]` format. +//! Two-part versions (e.g. `7.6`) are common; three-part versions +//! appear on newer firmware releases. The methods on [`FirmwareVersion`] +//! are used throughout the HAL to gate feature enablement based on +//! known firmware compatibility boundaries. + +#![allow(dead_code)] + +use std::fmt; + +/// Parsed firmware version supporting semantic comparison. +/// +/// Each version is split on `.` — the first component becomes `major`, +/// the second `minor`, and an optional third becomes `patch`. The raw +/// string is preserved for display purposes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FirmwareVersion { + pub major: u16, + pub minor: u16, + pub patch: u16, + pub raw: String, +} + +impl FirmwareVersion { + /// Parse a version string in `major.minor` or `major.minor.patch` format. + /// + /// Returns `None` if the string has fewer than two components, or if + /// any component is not a valid unsigned integer. + pub fn parse(version: &str) -> Option { + let parts: Vec<&str> = version.split('.').collect(); + let major = parts.first()?.parse().ok()?; + let minor = parts.get(1)?.parse().ok()?; + let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + Some(Self { + major, + minor, + patch, + raw: version.to_string(), + }) + } + + /// Returns `true` when `self >= (major, minor)`. + /// + /// Only major and minor are compared; patch is ignored so that + /// two-part version strings (e.g. `7.2`) compare correctly with + /// three-part ones (e.g. `7.2.1`). + pub fn is_at_least(&self, major: u16, minor: u16) -> bool { + self.major > major || (self.major == major && self.minor >= minor) + } + + /// Returns `true` when `lo <= self <= hi`. + /// + /// The upper bound is inclusive. Calls [`is_at_least`](Self::is_at_least) + /// for the lower bound and performs a symmetric comparison for the upper. + pub fn is_between(&self, lo_major: u16, lo_minor: u16, hi_major: u16, hi_minor: u16) -> bool { + self.is_at_least(lo_major, lo_minor) + && (self.major < hi_major || (self.major == hi_major && self.minor <= hi_minor)) + } +} + +impl fmt::Display for FirmwareVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.raw) + } +} + +impl Default for FirmwareVersion { + fn default() -> Self { + Self { + major: 0, + minor: 0, + patch: 0, + raw: "0.0".into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_two_part_version() { + let v = FirmwareVersion::parse("7.6").unwrap(); + assert_eq!(v.major, 7); + assert_eq!(v.minor, 6); + assert_eq!(v.patch, 0); + assert_eq!(v.raw, "7.6"); + } + + #[test] + fn test_parse_three_part_version() { + let v = FirmwareVersion::parse("5.7.4").unwrap(); + assert_eq!(v.major, 5); + assert_eq!(v.minor, 7); + assert_eq!(v.patch, 4); + assert_eq!(v.raw, "5.7.4"); + } + + #[test] + fn test_parse_single_part_fails() { + assert!(FirmwareVersion::parse("7").is_none()); + } + + #[test] + fn test_parse_non_numeric_fails() { + assert!(FirmwareVersion::parse("a.b").is_none()); + assert!(FirmwareVersion::parse("7.x").is_none()); + } + + #[test] + fn test_parse_empty_fails() { + assert!(FirmwareVersion::parse("").is_none()); + } + + #[test] + fn test_is_at_least_exact_match() { + let v = FirmwareVersion::parse("7.2").unwrap(); + assert!(v.is_at_least(7, 2)); + } + + #[test] + fn test_is_at_least_above() { + let v = FirmwareVersion::parse("7.6").unwrap(); + assert!(v.is_at_least(7, 2)); + assert!(v.is_at_least(6, 0)); + assert!(v.is_at_least(7, 6)); + } + + #[test] + fn test_is_at_least_below() { + let v = FirmwareVersion::parse("7.0").unwrap(); + assert!(!v.is_at_least(7, 2)); + assert!(!v.is_at_least(8, 0)); + } + + #[test] + fn test_is_between_inclusive_range() { + let v = FirmwareVersion::parse("7.2").unwrap(); + assert!(v.is_between(6, 0, 8, 0)); + assert!(v.is_between(7, 0, 7, 2)); + assert!(v.is_between(7, 2, 7, 2)); + } + + #[test] + fn test_is_between_outside_range() { + let v = FirmwareVersion::parse("7.6").unwrap(); + assert!(!v.is_between(6, 0, 7, 2)); + assert!(!v.is_between(8, 0, 9, 0)); + } + + #[test] + fn test_default_version() { + let v = FirmwareVersion::default(); + assert_eq!(v.major, 0); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.raw, "0.0"); + } + + #[test] + fn test_display() { + let v = FirmwareVersion::parse("7.6.1").unwrap(); + assert_eq!(v.to_string(), "7.6.1"); + } + + #[test] + fn test_parse_with_patch_zero() { + let v = FirmwareVersion::parse("7.6.0").unwrap(); + assert_eq!(v.major, 7); + assert_eq!(v.minor, 6); + assert_eq!(v.patch, 0); + } + + #[test] + fn test_legacy_fido_config_boundaries() { + // <= 7.2 supports legacy FIDO hardware config + assert!(FirmwareVersion::parse("7.2").unwrap().is_at_least(0, 0)); + assert!( + !FirmwareVersion::parse("7.3") + .unwrap() + .is_between(0, 0, 7, 2) + ); + assert!( + FirmwareVersion::parse("7.2") + .unwrap() + .is_between(0, 0, 7, 2) + ); + assert!( + FirmwareVersion::parse("6.6") + .unwrap() + .is_between(0, 0, 7, 2) + ); + } +} diff --git a/src/hal/fido/constants.rs b/src/hal/fido/constants.rs index b542801..595f9b2 100644 --- a/src/hal/fido/constants.rs +++ b/src/hal/fido/constants.rs @@ -27,6 +27,8 @@ use std::fmt; +pub use crate::hal::common::cose::{CoseAlgorithm, CoseCurve, CoseKeyParam}; + // ══════════════════════════════════════════════════════════════════════════════ // CTAP2 STANDARD — FIDO Alliance specification §8.1 // ══════════════════════════════════════════════════════════════════════════════ @@ -343,169 +345,6 @@ bitflags::bitflags! { } } -// ── COSE key types (RFC 8152) ─────────────────────────────────────────────── - -/// COSE algorithm identifiers (IANA COSE Algorithms registry). -/// -/// Used in `pubKeyCredParams` to specify which signature algorithms -/// the platform supports. The authenticator picks the first match. -#[repr(i32)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CoseAlgorithm { - /// ECDSA with P-256 and SHA-256 (most common for WebAuthn). - ES256 = -7, - /// EdDSA with Ed25519. - EdDSA = -8, - /// ECDSA with P-256 (alternate ID, same as ES256). - ESP256 = -9, - /// EdDSA with Ed25519 (alternate ID). - Ed25519 = -19, - /// ECDH-ES with HKDF-256 key agreement. - EcdhEsHkdf256 = -25, - /// ECDSA with P-384 and SHA-384. - ES384 = -35, - /// ECDSA with P-521 and SHA-512. - ES512 = -36, - /// ECDSA with secp256k1 and SHA-256 (Bitcoin curve). - ES256K = -47, - /// ECDSA with P-384 (alternate ID). - ESP384 = -51, - /// ECDSA with P-521 (alternate ID). - ESP512 = -52, - /// EdDSA with Ed448. - Ed448 = -53, - /// RSASSA-PKCS1-v1_5 with SHA-256. - RS256 = -257, - /// RSASSA-PKCS1-v1_5 with SHA-384. - RS384 = -258, - /// RSASSA-PKCS1-v1_5 with SHA-512. - RS512 = -259, - /// ECDSA with brainpool256r1 and SHA-256. - ESB256 = -265, - /// ECDSA with brainpool384r1 and SHA-384. - ESB384 = -267, - /// ECDSA with brainpool512r1 and SHA-512. - ESB512 = -268, - /// ML-DSA-44 (FIPS 204, Level 2) — post-quantum signing. - /// - /// RS-Key specific. Uses COSE key type AKP (7) instead of EC2/OKP. - MLDSA44 = -48, - /// ML-DSA-65 (FIPS 204, Level 3) — declared in getInfo but may be - /// unsupported for credential creation. - MLDSA65 = -49, - /// ML-DSA-87 (FIPS 204, Level 5) — declared in getInfo but may be - /// unsupported for credential creation. - MLDSA87 = -50, -} - -impl CoseAlgorithm { - /// Convert a raw i128 (from CBOR) to a [`CoseAlgorithm`]. - pub fn from_i128(val: i128) -> Option { - match val as i32 { - -7 => Some(Self::ES256), - -8 => Some(Self::EdDSA), - -9 => Some(Self::ESP256), - -19 => Some(Self::Ed25519), - -25 => Some(Self::EcdhEsHkdf256), - -35 => Some(Self::ES384), - -36 => Some(Self::ES512), - -47 => Some(Self::ES256K), - -51 => Some(Self::ESP384), - -52 => Some(Self::ESP512), - -53 => Some(Self::Ed448), - -257 => Some(Self::RS256), - -258 => Some(Self::RS384), - -259 => Some(Self::RS512), - -265 => Some(Self::ESB256), - -267 => Some(Self::ESB384), - -268 => Some(Self::ESB512), - -48 => Some(Self::MLDSA44), - -49 => Some(Self::MLDSA65), - -50 => Some(Self::MLDSA87), - _ => None, - } - } -} - -impl fmt::Display for CoseAlgorithm { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ES256 => write!(f, "ES256"), - Self::EdDSA => write!(f, "EdDSA"), - Self::ESP256 => write!(f, "ESP256"), - Self::Ed25519 => write!(f, "Ed25519"), - Self::EcdhEsHkdf256 => write!(f, "ECDH-ES-HKDF-256"), - Self::ES384 => write!(f, "ES384"), - Self::ES512 => write!(f, "ES512"), - Self::ES256K => write!(f, "ES256K"), - Self::ESP384 => write!(f, "ESP384"), - Self::ESP512 => write!(f, "ESP512"), - Self::Ed448 => write!(f, "Ed448"), - Self::RS256 => write!(f, "RS256"), - Self::RS384 => write!(f, "RS384"), - Self::RS512 => write!(f, "RS512"), - Self::ESB256 => write!(f, "ESB256"), - Self::ESB384 => write!(f, "ESB384"), - Self::ESB512 => write!(f, "ESB512"), - Self::MLDSA44 => write!(f, "ML-DSA-44"), - Self::MLDSA65 => write!(f, "ML-DSA-65"), - Self::MLDSA87 => write!(f, "ML-DSA-87"), - } - } -} - -/// COSE elliptic curve identifiers (RFC 8152 §13.1.1). -#[repr(u8)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CoseCurve { - /// NIST P-256 (secp256r1, prime256v1). - P256 = 1, - /// NIST P-384 (secp384r1). - P384 = 2, - /// NIST P-521 (secp521r1). - P521 = 3, - /// X25519 for key agreement. - X25519 = 4, - /// X448 for key agreement. - X448 = 5, - /// Ed25519 for signing. - Ed25519 = 6, - /// Ed448 for signing. - Ed448 = 7, - /// secp256k1 (Bitcoin/Ethereum curve). - P256K1 = 8, - /// BrainpoolP256R1. - BP256R1 = 9, - /// BrainpoolP384R1. - BP384R1 = 10, - /// BrainpoolP512R1. - BP512R1 = 11, -} - -/// COSE key parameter identifiers (RFC 8152 §7.1). -#[repr(i32)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CoseKeyParam { - /// Key type (OKP, EC2, RSA, etc.). - Kty = 1, - /// Key identifier. - Kid = 2, - /// Algorithm identifier. - Alg = 3, - /// Key operations (sign, verify, encrypt, etc.). - KeyOps = 4, - /// Base IV for symmetric operations. - BaseIV = 5, - /// Elliptic curve identifier. - Crv = -1, - /// X coordinate (EC2) or public key bytes (OKP). - X = -2, - /// Y coordinate (EC2). - Y = -3, - /// Private key (EC2 or OKP). - D = -4, -} - // ── CTAP2 errors (§8.2) ──────────────────────────────────────────────────── /// CTAP2 error codes (§8.2). @@ -609,7 +448,7 @@ pub enum Ctap2Error { /// - **All versions**: Backup(0x01), MSE(0x02), Unlock(0x03), EA(0x04) /// - **≤ v7.2**: PhysicalOptions(0x05), Memory(0x06) — removed in later /// releases. PicoForge keeps them for legacy device support. -/// - **Current**: AdminPin(0x08) added. +/// - **≥ v7.6**: AdminPin(0x08) added. /// /// RS-Key uses a different vendor command scheme (CTAPHID 0x41 with /// 64-bit sub-command IDs) — this enum does NOT apply to RS-Key. @@ -632,6 +471,8 @@ pub enum VendorCommand { /// /// **Legacy** (pico-fido ≤ v7.2 only). Removed in current firmware. Memory = 0x06, + /// Admin PIN operations (added in pico-fido v7.6). + AdminPin = 0x08, } /// Pico-fido vendor config command IDs (64-bit). @@ -869,11 +710,31 @@ pub const CTAP_VENDOR_CONFIG_CMD: u8 = 0xC2; /// RS-Key CTAPHID vendor command (0x41). /// /// Carries CBOR-encoded sub-commands for seed backup, attestation, -/// and audit operations. This is RS-Key specific and not part of pico-fido. +/// audit operations, and PicoForge hardware config. +/// This is RS-Key specific and not part of pico-fido. /// /// See [RS-Key protocol §9](https://themaxmur.github.io/RS-Key/develop/) for details. pub const RSKEY_CTAPHID_VENDOR_CMD: u8 = 0x41; +/// RS-Key CONFIG_READ sub-command ID (0x0D). +/// +/// Reads device configuration over FIDO. Supports DEV_CONF (0x00), +/// PHY (0x01), and LED (0x02) targets. Ungated — no PIN needed. +pub const RSKEY_CONFIG_READ: u8 = 0x0D; + +/// RS-Key CONFIG_WRITE sub-command ID (0x0C). +/// +/// Writes device configuration over FIDO. Supports the same targets +/// as CONFIG_READ. Requires ACFG-gated PIN token. +pub const RSKEY_CONFIG_WRITE: u8 = 0x0C; + +/// RS-Key config target: device configuration (VID, PID, serial, product name). +pub const RSKEY_CFG_TARGET_DEV_CONF: u8 = 0x00; +/// RS-Key config target: physical config (LED GPIO, brightness, options). +pub const RSKEY_CFG_TARGET_PHY: u8 = 0x01; +/// RS-Key config target: LED status config. +pub const RSKEY_CFG_TARGET_LED: u8 = 0x02; + // ══════════════════════════════════════════════════════════════════════════════ // SHARED PROTOCOL CONSTANTS // ══════════════════════════════════════════════════════════════════════════════ @@ -920,3 +781,532 @@ pub const MAX_LARGE_BLOB_SIZE: usize = 2048; pub const AAGUID: [u8; 16] = [ 0x89, 0xFB, 0x94, 0xB7, 0x06, 0xC9, 0x36, 0x73, 0x9B, 0x7E, 0x30, 0x52, 0x6D, 0x96, 0x81, 0x45, ]; + +/// CTAP 2.1 GetInfo response map keys (§11.5.3). +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ctap2GetInfoKey { + Versions = 0x01, + Extensions = 0x02, + Aaguid = 0x03, + Options = 0x04, + MaxMsgSize = 0x05, + PinUvAuthProtocols = 0x06, + MaxCredentialCountInList = 0x07, + MaxCredentialIdLength = 0x08, + RemainingDiscoverableCredentials = 0x14, + FirmwareVersion = 0x0E, +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── CTAP2 command codes ────────────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h: #define CTAP_MAKE_CREDENTIAL 0x01 + // CTAP_GET_ASSERTION 0x02 + // CTAP_GET_INFO 0x04 + // CTAP_CLIENT_PIN 0x06 + // CTAP_RESET 0x07 + // CTAP_GET_NEXT_ASSERTION 0x08 + // CTAP_CREDENTIAL_MGMT 0x0A + // CTAP_SELECTION 0x0B + // CTAP_LARGE_BLOBS 0x0C + // CTAP_CONFIG 0x0D + + #[test] + fn test_ctap_command_values_match_firmware() { + assert_eq!(CtapCommand::MakeCredential as u8, 0x01); + assert_eq!(CtapCommand::GetAssertion as u8, 0x02); + assert_eq!(CtapCommand::GetInfo as u8, 0x04); + assert_eq!(CtapCommand::ClientPin as u8, 0x06); + assert_eq!(CtapCommand::Reset as u8, 0x07); + assert_eq!(CtapCommand::GetNextAssertion as u8, 0x08); + assert_eq!(CtapCommand::CredentialMgmt as u8, 0x0A); + assert_eq!(CtapCommand::Selection as u8, 0x0B); + assert_eq!(CtapCommand::LargeBlobs as u8, 0x0C); + assert_eq!(CtapCommand::Config as u8, 0x0D); + } + + // ── U2F command codes ──────────────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h: #define CTAP_REGISTER 0x01 + // CTAP_AUTHENTICATE 0x02 + // CTAP_VERSION 0x03 + + #[test] + fn test_u2f_command_values_match_firmware() { + assert_eq!(U2fCommand::Register as u8, 0x01); + assert_eq!(U2fCommand::Authenticate as u8, 0x02); + assert_eq!(U2fCommand::Version as u8, 0x03); + } + + // ── AuthenticateControl ────────────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h: #define CTAP_AUTH_ENFORCE 0x03 + // CTAP_AUTH_CHECK_ONLY 0x07 + + #[test] + fn test_authenticate_control_values_match_firmware() { + assert_eq!(AuthenticateControl::EnforceUserPresence as u8, 0x03); + assert_eq!(AuthenticateControl::CheckOnly as u8, 0x07); + } + + // ── Client PIN sub-commands ────────────────────────────────────────────── + // Reference: CTAP 2.1 §11.5.4 + + #[test] + fn test_client_pin_sub_command_values_match_spec() { + assert_eq!(ClientPinSubCommand::GetPinRetries as u8, 0x01); + assert_eq!(ClientPinSubCommand::GetKeyAgreement as u8, 0x02); + assert_eq!(ClientPinSubCommand::SetPin as u8, 0x03); + assert_eq!(ClientPinSubCommand::ChangePin as u8, 0x04); + assert_eq!(ClientPinSubCommand::GetPinToken as u8, 0x05); + assert_eq!( + ClientPinSubCommand::GetPinUvAuthTokenUsingUvWithPermissions as u8, + 0x06 + ); + assert_eq!(ClientPinSubCommand::GetUvRetries as u8, 0x07); + assert_eq!( + ClientPinSubCommand::GetPinUvAuthTokenUsingPinWithPermissions as u8, + 0x09 + ); + } + + // ── Config sub-commands ────────────────────────────────────────────────── + // Reference: CTAP 2.1 §11.5.10 + + #[test] + fn test_config_sub_command_values_match_spec() { + assert_eq!(ConfigSubCommand::EnableEnterpriseAttestation as u8, 0x01); + assert_eq!(ConfigSubCommand::ToggleAlwaysUv as u8, 0x02); + assert_eq!(ConfigSubCommand::SetMinPinLength as u8, 0x03); + assert_eq!(ConfigSubCommand::VendorPrototype as u8, 0xFF); + } + + // ── AuthenticatorFlags ─────────────────────────────────────────────────── + // Reference: pico-fido src/fido/fido.h: + // #define FIDO2_AUT_FLAG_UP 0x1 + // #define FIDO2_AUT_FLAG_UV 0x4 + // #define FIDO2_AUT_FLAG_AT 0x40 + // #define FIDO2_AUT_FLAG_ED 0x80 + + #[test] + fn test_authenticator_flags_values_match_firmware() { + assert_eq!(AuthenticatorFlags::USER_PRESENT.bits(), 0x01); + assert_eq!(AuthenticatorFlags::USER_VERIFIED.bits(), 0x04); + assert_eq!(AuthenticatorFlags::ATTESTED_CREDENTIAL_DATA.bits(), 0x40); + assert_eq!(AuthenticatorFlags::EXTENSION_DATA.bits(), 0x80); + } + + #[test] + fn test_authenticator_flags_combine_correctly() { + let up_uv = AuthenticatorFlags::USER_PRESENT | AuthenticatorFlags::USER_VERIFIED; + assert_eq!(up_uv.bits(), 0x05); + let full = up_uv + | AuthenticatorFlags::ATTESTED_CREDENTIAL_DATA + | AuthenticatorFlags::EXTENSION_DATA; + assert_eq!(full.bits(), 0xC5); + } + + // ── AuthenticatorOptions ───────────────────────────────────────────────── + // Reference: pico-fido src/fido/fido.h: + // #define FIDO2_OPT_EA 0x01 + // #define FIDO2_OPT_AUV 0x02 + + #[test] + fn test_authenticator_options_values_match_firmware() { + assert_eq!(AuthenticatorOptions::ENTERPRISE_ATTESTATION.bits(), 0x01); + assert_eq!(AuthenticatorOptions::USER_VERIFICATION.bits(), 0x02); + } + + // ── PinUvAuthTokenPermissions ──────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h: + // #define CTAP_PERMISSION_MC 0x01 + // #define CTAP_PERMISSION_GA 0x02 + // #define CTAP_PERMISSION_CM 0x04 + // #define CTAP_PERMISSION_BE 0x08 + // #define CTAP_PERMISSION_LBW 0x10 + // #define CTAP_PERMISSION_ACFG 0x20 + // #define CTAP_PERMISSION_PCMR 0x40 + + #[test] + fn test_pin_uv_auth_token_permissions_values_match_firmware() { + assert_eq!(PinUvAuthTokenPermissions::MAKE_CREDENTIAL.bits(), 0x01); + assert_eq!(PinUvAuthTokenPermissions::GET_ASSERTION.bits(), 0x02); + assert_eq!( + PinUvAuthTokenPermissions::CREDENTIAL_MANAGEMENT.bits(), + 0x04 + ); + assert_eq!(PinUvAuthTokenPermissions::BIO_ENROLLMENT.bits(), 0x08); + assert_eq!(PinUvAuthTokenPermissions::LARGE_BLOB_WRITE.bits(), 0x10); + assert_eq!(PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG.bits(), 0x20); + assert_eq!( + PinUvAuthTokenPermissions::PER_CREDENTIAL_MGMT_READONLY.bits(), + 0x40 + ); + } + + #[test] + fn test_pin_uv_auth_token_permissions_combine() { + let mc_ga = + PinUvAuthTokenPermissions::MAKE_CREDENTIAL | PinUvAuthTokenPermissions::GET_ASSERTION; + assert_eq!(mc_ga.bits(), 0x03); + let all = mc_ga + | PinUvAuthTokenPermissions::CREDENTIAL_MANAGEMENT + | PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG; + assert_eq!(all.bits(), 0x27); + } + + // ── COSE Algorithms ────────────────────────────────────────────────────── + // Reference: pico-fido src/fido/fido.h: #define FIDO2_ALG_* defines + + #[test] + fn test_cose_algorithm_values_match_firmware() { + assert_eq!(CoseAlgorithm::ES256 as i32, -7); + assert_eq!(CoseAlgorithm::EdDSA as i32, -8); + assert_eq!(CoseAlgorithm::ESP256 as i32, -9); + assert_eq!(CoseAlgorithm::Ed25519 as i32, -19); + assert_eq!(CoseAlgorithm::EcdhEsHkdf256 as i32, -25); + assert_eq!(CoseAlgorithm::ES384 as i32, -35); + assert_eq!(CoseAlgorithm::ES512 as i32, -36); + assert_eq!(CoseAlgorithm::ES256K as i32, -47); + assert_eq!(CoseAlgorithm::ESP384 as i32, -51); + assert_eq!(CoseAlgorithm::ESP512 as i32, -52); + assert_eq!(CoseAlgorithm::Ed448 as i32, -53); + assert_eq!(CoseAlgorithm::RS256 as i32, -257); + assert_eq!(CoseAlgorithm::RS384 as i32, -258); + assert_eq!(CoseAlgorithm::RS512 as i32, -259); + assert_eq!(CoseAlgorithm::ESB256 as i32, -265); + assert_eq!(CoseAlgorithm::ESB384 as i32, -267); + assert_eq!(CoseAlgorithm::ESB512 as i32, -268); + } + + #[test] + fn test_cose_algorithm_from_i128_roundtrip() { + let test_cases = [ + -7, -8, -9, -19, -25, -35, -36, -47, -51, -52, -53, -257, -258, -259, -265, -267, -268, + -48, -49, -50, + ]; + for val in test_cases { + let alg = CoseAlgorithm::from_i128(val as i128) + .unwrap_or_else(|| panic!("from_i128({}) failed", val)); + assert_eq!(alg as i32, val); + } + } + + #[test] + fn test_cose_algorithm_unknown_returns_none() { + assert!(CoseAlgorithm::from_i128(0).is_none()); + assert!(CoseAlgorithm::from_i128(1).is_none()); + assert!(CoseAlgorithm::from_i128(-1).is_none()); + assert!(CoseAlgorithm::from_i128(-100).is_none()); + assert!(CoseAlgorithm::from_i128(-300).is_none()); + } + + #[test] + fn test_cose_algorithm_display() { + assert_eq!(CoseAlgorithm::ES256.to_string(), "ES256"); + assert_eq!(CoseAlgorithm::EdDSA.to_string(), "EdDSA"); + assert_eq!(CoseAlgorithm::MLDSA44.to_string(), "ML-DSA-44"); + } + + // ── COSE Curves ────────────────────────────────────────────────────────── + // Reference: pico-fido src/fido/fido.h: + // #define FIDO2_CURVE_P256 1 #define FIDO2_CURVE_BP256R1 9 + // #define FIDO2_CURVE_P384 2 #define FIDO2_CURVE_BP384R1 10 + // #define FIDO2_CURVE_P521 3 #define FIDO2_CURVE_BP512R1 11 + // #define FIDO2_CURVE_X25519 4 + // #define FIDO2_CURVE_X448 5 + // #define FIDO2_CURVE_ED25519 6 + // #define FIDO2_CURVE_ED448 7 + // #define FIDO2_CURVE_P256K1 8 + + #[test] + fn test_cose_curve_values_match_firmware() { + assert_eq!(CoseCurve::P256 as u8, 1); + assert_eq!(CoseCurve::P384 as u8, 2); + assert_eq!(CoseCurve::P521 as u8, 3); + assert_eq!(CoseCurve::X25519 as u8, 4); + assert_eq!(CoseCurve::X448 as u8, 5); + assert_eq!(CoseCurve::Ed25519 as u8, 6); + assert_eq!(CoseCurve::Ed448 as u8, 7); + assert_eq!(CoseCurve::P256K1 as u8, 8); + assert_eq!(CoseCurve::BP256R1 as u8, 9); + assert_eq!(CoseCurve::BP384R1 as u8, 10); + assert_eq!(CoseCurve::BP512R1 as u8, 11); + } + + // ── CTAP2 error codes ──────────────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h: #define CTAP2_ERR_* defines + + #[test] + fn test_ctap2_error_values_match_firmware() { + assert_eq!(Ctap2Error::Success as u8, 0x00); + assert_eq!(Ctap2Error::CborUnexpectedType as u8, 0x11); + assert_eq!(Ctap2Error::InvalidCbor as u8, 0x12); + assert_eq!(Ctap2Error::MissingParameter as u8, 0x14); + assert_eq!(Ctap2Error::LimitExceeded as u8, 0x15); + assert_eq!(Ctap2Error::FpDatabaseFull as u8, 0x17); + assert_eq!(Ctap2Error::LargeBlobStorageFull as u8, 0x18); + assert_eq!(Ctap2Error::CredentialExcluded as u8, 0x19); + assert_eq!(Ctap2Error::Processing as u8, 0x21); + assert_eq!(Ctap2Error::InvalidCredential as u8, 0x22); + assert_eq!(Ctap2Error::UserActionPending as u8, 0x23); + assert_eq!(Ctap2Error::OperationPending as u8, 0x24); + assert_eq!(Ctap2Error::NoOperations as u8, 0x25); + assert_eq!(Ctap2Error::UnsupportedAlgorithm as u8, 0x26); + assert_eq!(Ctap2Error::OperationDenied as u8, 0x27); + assert_eq!(Ctap2Error::KeyStoreFull as u8, 0x28); + assert_eq!(Ctap2Error::UnsupportedOption as u8, 0x2B); + assert_eq!(Ctap2Error::InvalidOption as u8, 0x2C); + assert_eq!(Ctap2Error::KeepaliveCancel as u8, 0x2D); + assert_eq!(Ctap2Error::NoCredentials as u8, 0x2E); + assert_eq!(Ctap2Error::UserActionTimeout as u8, 0x2F); + assert_eq!(Ctap2Error::NotAllowed as u8, 0x30); + assert_eq!(Ctap2Error::PinInvalid as u8, 0x31); + assert_eq!(Ctap2Error::PinBlocked as u8, 0x32); + assert_eq!(Ctap2Error::PinAuthInvalid as u8, 0x33); + assert_eq!(Ctap2Error::PinAuthBlocked as u8, 0x34); + assert_eq!(Ctap2Error::PinNotSet as u8, 0x35); + assert_eq!(Ctap2Error::PuatRequired as u8, 0x36); + assert_eq!(Ctap2Error::PinPolicyViolation as u8, 0x37); + assert_eq!(Ctap2Error::RequestTooLarge as u8, 0x39); + assert_eq!(Ctap2Error::ActionTimeout as u8, 0x3A); + assert_eq!(Ctap2Error::UpRequired as u8, 0x3B); + assert_eq!(Ctap2Error::UvBlocked as u8, 0x3C); + assert_eq!(Ctap2Error::IntegrityFailure as u8, 0x3D); + assert_eq!(Ctap2Error::InvalidSubcommand as u8, 0x3E); + assert_eq!(Ctap2Error::UvInvalid as u8, 0x3F); + assert_eq!(Ctap2Error::UnauthorizedPermission as u8, 0x40); + } + + // ── VendorCommand codes ────────────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h: + // #define CTAP_VENDOR_BACKUP 0x01 + // #define CTAP_VENDOR_MSE 0x02 + // #define CTAP_VENDOR_UNLOCK 0x03 + // #define CTAP_VENDOR_EA 0x04 + // #define CTAP_VENDOR_ADMIN_PIN 0x08 + // Note: PhysicalOptions(0x05) and Memory(0x06) are legacy (<=v7.2) and + // were removed in later firmware releases. + + #[test] + fn test_vendor_command_values_match_firmware() { + assert_eq!(VendorCommand::Backup as u8, 0x01); + assert_eq!(VendorCommand::ManageSecurityEnvironment as u8, 0x02); + assert_eq!(VendorCommand::Unlock as u8, 0x03); + assert_eq!(VendorCommand::EnterpriseAttestation as u8, 0x04); + // PhysicalOptions(0x05) and Memory(0x06) are legacy <=v7.2 + assert_eq!(VendorCommand::PhysicalOptions as u8, 0x05); + assert_eq!(VendorCommand::Memory as u8, 0x06); + assert_eq!(VendorCommand::AdminPin as u8, 0x08); + } + + // ── RS-Key vendor command ──────────────────────────────────────────────── + // Reference: RS-Key protocol docs §9 + + #[test] + fn test_rskey_vendor_cmd_value() { + assert_eq!(RSKEY_CTAPHID_VENDOR_CMD, 0x41); + } + + // ── Shared protocol constants ──────────────────────────────────────────── + // Reference: pico-fido src/fido/fido.h, src/fido/ctap.h + + #[test] + fn test_size_constants_match_firmware() { + assert_eq!(CTAP_APPID_SIZE, 32); + assert_eq!(CTAP_CHAL_SIZE, 32); + assert_eq!(CTAP_EC_KEY_SIZE, 32); + assert_eq!(CTAP_EC_POINT_SIZE, 65); + assert_eq!(CTAP_MAX_KH_SIZE, 128); + assert_eq!(KEY_HANDLE_LEN, 64); + assert_eq!(CTAP_MAX_EC_SIG_SIZE, 72); + assert_eq!(CTAP_CTR_SIZE, 4); + assert_eq!(MAX_PIN_RETRIES, 8); + assert_eq!(MAX_CREDENTIAL_COUNT_IN_LIST, 16); + assert_eq!(MAX_CRED_ID_LENGTH, 1024); + assert_eq!(MAX_RESIDENT_CREDENTIALS, 256); + assert_eq!(MAX_CREDBLOB_LENGTH, 128); + assert_eq!(MAX_MSG_SIZE, 1024); + assert_eq!(MAX_FRAGMENT_LENGTH, 960); + assert_eq!(MAX_LARGE_BLOB_SIZE, 2048); + } + + // ── Vendor config command IDs ──────────────────────────────────────────── + // Reference: pico-fido src/fido/ctap.h (tagged releases v3.0–v7.6) + // RS-Key protocol docs §11 (for physical config commands) + // + // PicoForge values match all tagged releases (v3.0–v7.6). The `main` + // branch restructured these to a 0x000X... prefix (unreleased) — not + // relevant for current version targeting. + // + // v3.0+: AuthEncryptionEnable(0x03e4...), AuthEncryptionDisable(0x1831...) + // v7.0+: EnterpriseAttestationUpload(0x66f2...), PinComplexityPolicy(0x6c07...) + // v7.0+: PhysicalOptions changed from 0x969f... (v6.0–6.4) to 0x269f... (v7.0+) + // main (unreleased): all changed to 0x000X... prefix, PHY options removed + + #[test] + fn test_vendor_config_command_from_u64() { + assert_eq!( + VendorConfigCommand::from_u64(0x03e43f56b34285e2), + Some(VendorConfigCommand::AuthEncryptionEnable) + ); + assert_eq!( + VendorConfigCommand::from_u64(0x1831a40f04a25ed9), + Some(VendorConfigCommand::AuthEncryptionDisable) + ); + assert_eq!( + VendorConfigCommand::from_u64(0x66f2a674c29a8dcf), + Some(VendorConfigCommand::EnterpriseAttestationUpload) + ); + assert_eq!( + VendorConfigCommand::from_u64(0x6c07d70fe96c3897), + Some(VendorConfigCommand::PinComplexityPolicy) + ); + } + + #[test] + fn test_physical_vendor_config_ids_match_rskey_protocol() { + // These 4 values are verified against RS-Key protocol docs §11 + assert_eq!( + VendorConfigCommand::from_u64(0x6fcb19b0cbe3acfa), + Some(VendorConfigCommand::PhysicalVidPid) + ); + assert_eq!( + VendorConfigCommand::from_u64(0x7b392a394de9f948), + Some(VendorConfigCommand::PhysicalLedGpio) + ); + assert_eq!( + VendorConfigCommand::from_u64(0x76a85945985d02fd), + Some(VendorConfigCommand::PhysicalLedBrightness) + ); + assert_eq!( + VendorConfigCommand::from_u64(0x269f3b09eceb805f), + Some(VendorConfigCommand::PhysicalOptions) + ); + } + + #[test] + fn test_vendor_config_command_unknown_returns_none() { + assert!(VendorConfigCommand::from_u64(0).is_none()); + assert!(VendorConfigCommand::from_u64(0xDEADBEEF).is_none()); + } + + #[test] + fn test_vendor_config_command_display() { + assert_eq!( + VendorConfigCommand::AuthEncryptionEnable.to_string(), + "AuthEncryptionEnable" + ); + assert_eq!( + VendorConfigCommand::PhysicalVidPid.to_string(), + "PhysicalVidPid" + ); + } + + // ── FidoCertification ──────────────────────────────────────────────────── + + #[test] + fn test_fido_certification_from_str() { + assert_eq!( + FidoCertification::from_str("0x03E43F56B34285E2"), + Some(FidoCertification::AuthEncryption) + ); + assert_eq!( + FidoCertification::from_str("03E43F56B34285E2"), + Some(FidoCertification::AuthEncryption) + ); + assert_eq!( + FidoCertification::from_str("0x6FCB19B0CBE3ACFA"), + Some(FidoCertification::PhysicalVidPid) + ); + assert!(FidoCertification::from_str("unknown").is_none()); + } + + #[test] + fn test_fido_certification_display() { + assert_eq!( + FidoCertification::AuthEncryption.to_string(), + "Auth Encryption" + ); + assert_eq!( + FidoCertification::PhysicalVidPid.to_string(), + "Physical VID/PID" + ); + assert_eq!( + FidoCertification::PinComplexity.to_string(), + "PIN Complexity" + ); + } + + // ── Credential management sub-commands ──────────────────────────────────── + + #[test] + fn test_credential_mgmt_sub_command_values() { + assert_eq!(CredentialMgmtSubCommand::GetCredsMetadata as u8, 0x01); + assert_eq!(CredentialMgmtSubCommand::EnumerateRpsBegin as u8, 0x02); + assert_eq!(CredentialMgmtSubCommand::EnumerateRpsGetNextRp as u8, 0x03); + assert_eq!( + CredentialMgmtSubCommand::EnumerateCredentialsBegin as u8, + 0x04 + ); + assert_eq!( + CredentialMgmtSubCommand::EnumerateCredentialsGetNextCredential as u8, + 0x05 + ); + assert_eq!(CredentialMgmtSubCommand::DeleteCredential as u8, 0x06); + assert_eq!(CredentialMgmtSubCommand::UpdateUserInformation as u8, 0x07); + } + + // ── AAGUID ─────────────────────────────────────────────────────────────── + + #[test] + fn test_aaguid_is_pico_fido_default() { + let expected: [u8; 16] = [ + 0x89, 0xFB, 0x94, 0xB7, 0x06, 0xC9, 0x36, 0x73, 0x9B, 0x7E, 0x30, 0x52, 0x6D, 0x96, + 0x81, 0x45, + ]; + assert_eq!(AAGUID, expected); + assert_eq!(AAGUID.len(), 16); + } + + // ── Vendor command constants ───────────────────────────────────────────── + + #[test] + fn test_vendor_cbor_and_config_cmds() { + assert_eq!(CTAP_VENDOR_CBOR_CMD, 0xC1); + assert_eq!(CTAP_VENDOR_CONFIG_CMD, 0xC2); + } + + // ── GetInfo response key constants ─────────────────────────────────────── + // These are the CTAP 2.1 GetInfo response keys + + #[test] + fn test_get_info_response_keys() { + assert_eq!(Ctap2GetInfoKey::Versions as u8, 0x01); + assert_eq!(Ctap2GetInfoKey::Extensions as u8, 0x02); + assert_eq!(Ctap2GetInfoKey::Aaguid as u8, 0x03); + assert_eq!(Ctap2GetInfoKey::Options as u8, 0x04); + assert_eq!(Ctap2GetInfoKey::MaxMsgSize as u8, 0x05); + assert_eq!(Ctap2GetInfoKey::PinUvAuthProtocols as u8, 0x06); + assert_eq!(Ctap2GetInfoKey::MaxCredentialCountInList as u8, 0x07); + assert_eq!(Ctap2GetInfoKey::MaxCredentialIdLength as u8, 0x08); + assert_eq!(Ctap2GetInfoKey::FirmwareVersion as u8, 0x0E); + assert_eq!( + Ctap2GetInfoKey::RemainingDiscoverableCredentials as u8, + 0x14 + ); + } + + // ── MakeCredential param keys ──────────────────────────────────────────── + + #[test] + fn test_make_credential_param_keys() { + assert_eq!(MakeCredentialParam::ClientDataHash as u8, 0x01); + assert_eq!(MakeCredentialParam::Rp as u8, 0x02); + assert_eq!(MakeCredentialParam::User as u8, 0x03); + assert_eq!(MakeCredentialParam::PubKeyCredParams as u8, 0x04); + assert_eq!(MakeCredentialParam::ExcludeList as u8, 0x05); + assert_eq!(MakeCredentialParam::EnterpriseAttestation as u8, 0x0A); + } +} diff --git a/src/hal/fido/mod.rs b/src/hal/fido/mod.rs index 182eaa2..b4ccdd0 100644 --- a/src/hal/fido/mod.rs +++ b/src/hal/fido/mod.rs @@ -4,7 +4,7 @@ //! fido/ //! ├── mod.rs — high-level FIDO2 operations (info, PIN, credentials, config) //! ├── constants.rs — CTAP2 command codes, CBOR map keys, COSE algorithms, bitflags -//! └── hid.rs — USB HID transport (CTAPHID framing, channel init, CBOR exchange) +//! └── ops.rs — FidoOperations trait impl (CTAPHID framing, PIN, credential mgmt) //! ``` //! //! # Architecture @@ -18,20 +18,20 @@ //! fido::read_device_details() ← this file //! │ //! ▼ -//! HidTransport::open() ← hid.rs +//! HidTransport::open() ← transport/fido.rs //! │ //! ▼ //! USB HID (CTAPHID protocol) //! ``` //! -//! [`constants`] is imported by both `mod.rs` and `hid.rs` and should be the +//! [`constants`] is imported by both `mod.rs` and `ops.rs` and should be the //! single source of truth for every CTAP2-defined byte value. If you need to //! add a new command, sub-command, or CBOR key, put it there. //! -//! [`hid`] owns the raw byte-level exchange: channel ID negotiation, packet +//! [`ops`] implements the [`FidoOperations`] trait on [`HidTransport`], +//! owning the raw byte-level exchange: channel ID negotiation, packet //! framing (init + continuation packets), PIN token acquisition, ECDH key -//! agreement, and CBOR serialization. It exposes [`HidTransport`] which the -//! rest of the module uses for all device I/O. +//! agreement, and CBOR serialization. //! //! This module contains the public functions called from [`super::io`]. //! Each function opens an [`HidTransport`], performs the CTAP2 operation, @@ -41,32 +41,38 @@ //! //! Pico-fido firmware exposes vendor-specific CTAP commands (`0xC1`, `0xC2`) //! for hardware configuration (VID/PID, LED, memory stats). These are handled -//! through [`HidTransport::send_vendor_config`] and the +//! through [`send_vendor_config`](ops::FidoOperations::send_vendor_config) and the //! [`VendorConfigCommand`] enum in constants. Legacy firmware (≤7.2) uses a -//! different physical-options encoding; see `firmware_supports_legacy_fido_hardware_config`. +//! different physical-options encoding; see `AnyFirmware::supports_legacy_fido_hardware_config`. //! //! # Adding a new FIDO2 operation //! //! 1. Add any new command/sub-command enums to [`constants`]. -//! 2. Implement the CBOR encoding and transport call in [`hid`] (if it +//! 2. Implement the CBOR encoding and transport call in [`ops`] (if it //! requires new framing or PIN token logic). //! 3. Add the high-level function in this file, following the pattern: //! open transport → build CBOR payload → send → parse response → return. //! 4. Expose it through [`super::io`]. pub mod constants; -pub mod hid; +pub mod ops; +use crate::hal::transport::fido::{CTAPHID_CBOR, HidTransport}; use crate::{ error::PFError, - hal::types::{ - AppConfig, AppConfigInput, DeviceInfo, DeviceMethod, FidoDeviceInfo, FirmwareType, - FullDeviceStatus, PICOFIDO_AAGUID, RSKEY_AAGUID, StoredCredential, + hal::{ + firmwares::AnyFirmware, + types::{ + AppConfig, AppConfigInput, DeviceInfo, DeviceMethod, FidoDeviceInfo, FirmwareType, + FullDeviceStatus, LKONE_AAGUID, LedStatusConfig, PICOFIDO_AAGUID, RSKEY_AAGUID, + StoredCredential, + }, }, }; use base64::{Engine as _, engine::general_purpose}; use constants::*; -use hid::*; +use ops::FidoOperations; + use serde_cbor_2::{Value, from_slice, to_vec}; use std::collections::BTreeMap; @@ -74,6 +80,23 @@ const LEGACY_PHY_OPT_DIMMABLE: u16 = 0x02; const LEGACY_PHY_OPT_DISABLE_POWER_RESET: u16 = 0x04; const LEGACY_PHY_OPT_LED_STEADY: u16 = 0x08; +// PHY tag constants for RS-Key FIDO config (mirrors rescue PhyTag) +const RSKEY_PHY_TAG_VIDPID: u8 = 0x00; +const RSKEY_PHY_TAG_LED_GPIO: u8 = 0x04; +const RSKEY_PHY_TAG_LED_BRIGHTNESS: u8 = 0x05; +const RSKEY_PHY_TAG_OPTS: u8 = 0x06; +const RSKEY_PHY_TAG_PRESENCE_TIMEOUT: u8 = 0x08; +const RSKEY_PHY_TAG_USB_PRODUCT: u8 = 0x09; +const RSKEY_PHY_TAG_CURVES: u8 = 0x0A; +const RSKEY_PHY_TAG_ENABLED_USB_ITF: u8 = 0x0B; +const RSKEY_PHY_TAG_LED_DRIVER: u8 = 0x0C; +const RSKEY_PHY_TAG_LED_ORDER: u8 = 0x0D; +const RSKEY_PHY_TAG_LED_NUM: u8 = 0x0E; + +const RSKEY_OPT_DIMMABLE: u16 = 0x02; +const RSKEY_OPT_DISABLE_POWER_RESET: u16 = 0x04; +const RSKEY_OPT_LED_STEADY: u16 = 0x08; + // Fido functions that require pin: pub(crate) fn get_fido_info() -> Result { @@ -297,11 +320,20 @@ fn parse_fido_get_info(info_val: &Value) -> Result { } } - let firmware_version = format!( - "{}.{}", - (firmware_version_raw >> 8) & 0xFF, - firmware_version_raw & 0xFF - ); + let firmware_version = if firmware_version_raw > 0xFFFF { + format!( + "{}.{}.{}", + (firmware_version_raw >> 16) & 0xFF, + (firmware_version_raw >> 8) & 0xFF, + firmware_version_raw & 0xFF + ) + } else { + format!( + "{}.{}", + (firmware_version_raw >> 8) & 0xFF, + firmware_version_raw & 0xFF + ) + }; log::info!( "FIDO GetInfo parsed: {} versions, {} extensions, AAGUID={}, FW={}", @@ -373,21 +405,6 @@ fn parse_get_info_extension_list( } } -pub(crate) fn firmware_supports_legacy_fido_hardware_config(version: &str) -> bool { - let Some((major, minor)) = parse_firmware_version(version) else { - return false; - }; - - major < 7 || (major == 7 && minor <= 2) -} - -fn parse_firmware_version(version: &str) -> Option<(u16, u16)> { - let mut parts = version.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - Some((major, minor)) -} - pub(crate) fn change_fido_pin( current_pin: Option, new_pin: String, @@ -567,14 +584,49 @@ pub(crate) fn reset_device() -> Result { // Custom Fido functions ( works only with pico-fido firmware ) #[derive(Debug, Default, Clone, PartialEq, Eq)] -struct ManagementInfo { - serial: Option, - firmware_version: Option, - usb_supported: Option, - usb_enabled: Option, - config_locked: Option, +pub(crate) struct ManagementInfo { + pub serial: Option, + pub firmware_version: Option, + pub usb_supported: Option, + pub usb_enabled: Option, + pub config_locked: Option, } +pub(crate) fn read_rskey_management_info( + transport: &HidTransport, +) -> Result { + match transport.rs_key_config_read(RSKEY_CFG_TARGET_DEV_CONF) { + Ok(raw) if raw.len() > 1 => { + let data = if raw.first().copied() == Some(raw.len().saturating_sub(1) as u8) { + &raw[1..] + } else { + &raw[..] + }; + parse_management_info(data).map_err(|e| { + PFError::Device(format!("Failed to parse RS-Key management config: {e}")) + }) + } + Ok(_) => Err(PFError::Device( + "RS-Key FIDO management config response too short".to_string(), + )), + Err(_) => { + // DEV_CONF target not readable — fall back to legacy + // 0xC2 management info read (same as pico-fido). + read_management_info(transport).ok_or_else(|| { + PFError::Device( + "Failed to read management info over FIDO (0x41 and 0xC2 both rejected)" + .to_string(), + ) + }) + } + } +} + +/// Read full device status (info, config, security flags) over the FIDO HID transport. +/// +/// Opens a CTAPHID channel, performs `GetInfo`, reads hardware config +/// via vendor or standard config commands, and returns everything as +/// a [`FullDeviceStatus`]. pub fn read_device_details() -> Result { log::info!("Starting FIDO device details read..."); @@ -595,8 +647,21 @@ pub fn read_device_details() -> Result { fido_info.firmware_version ); - let supports_legacy_hardware_config = - firmware_supports_legacy_fido_hardware_config(&fido_info.firmware_version); + let firmware_type = if fido_info.aaguid == RSKEY_AAGUID { + FirmwareType::RSKey + } else if fido_info.aaguid == PICOFIDO_AAGUID || fido_info.aaguid == LKONE_AAGUID { + FirmwareType::PicoFido + } else { + FirmwareType::Unknown + }; + let has_legacy_vendor = + firmware_type == FirmwareType::PicoFido && probe_legacy_vendor_support(&transport); + let firmware = AnyFirmware::new_with_legacy( + firmware_type.clone(), + &fido_info.firmware_version, + has_legacy_vendor, + ); + let supports_legacy_hardware_config = firmware.supports_legacy_fido_hardware_config(); let management = read_management_info(&transport); let config = AppConfig { vid: format!("{:04X}", transport.vid), @@ -604,7 +669,12 @@ pub fn read_device_details() -> Result { product_name: transport.product_name.clone(), ..Default::default() }; - let config = if supports_legacy_hardware_config { + let config = if firmware_type == FirmwareType::RSKey { + // RS-Key uses 0x41 CONFIG_READ via CTAPHID_CBOR — not the + // legacy 0xC1 vendor command. Always attempt it; pre-v0.3.1 + // firmware gracefully returns the config unchanged with a log. + read_rskey_physical_config(&transport, config) + } else if supports_legacy_hardware_config { read_legacy_physical_config(&transport, config) } else { config @@ -629,14 +699,6 @@ pub fn read_device_details() -> Result { .unwrap_or_else(|| "Unknown".to_string()) }; - let firmware_type = if fido_info.aaguid == RSKEY_AAGUID { - FirmwareType::RSKey - } else if fido_info.aaguid == PICOFIDO_AAGUID { - FirmwareType::PicoFido - } else { - FirmwareType::Unknown - }; - Ok(FullDeviceStatus { info: DeviceInfo { serial: management @@ -650,7 +712,7 @@ pub fn read_device_details() -> Result { secure_boot: false, secure_lock: false, method: DeviceMethod::Fido, - firmware_type, + firmware_type: firmware.firmware_type(), }) } @@ -786,6 +848,28 @@ fn read_legacy_memory_stats(transport: &HidTransport) -> Result bool { + let mut params = BTreeMap::new(); + params.insert( + Value::Integer(1), + Value::Integer(PhysicalOptionsSubCommand::GetOptions as i128), + ); + let Ok(phy_cbor) = to_vec(&Value::Map(params)) else { + return false; + }; + let mut phy_payload = vec![VendorCommand::PhysicalOptions as u8]; + phy_payload.extend(phy_cbor); + match transport.send_cbor(CTAP_VENDOR_CBOR_CMD, &phy_payload) { + Ok(resp) => from_slice::(&resp).is_ok(), + Err(_) => false, + } +} + fn read_legacy_physical_config(transport: &HidTransport, mut config: AppConfig) -> AppConfig { let mut phy_params = BTreeMap::new(); phy_params.insert( @@ -818,6 +902,245 @@ fn read_legacy_physical_config(transport: &HidTransport, mut config: AppConfig) config } +/// Read PHY configuration from an RS-Key via CTAPHID 0x41 CONFIG_READ. +/// +/// Falls back to returning the unchanged config if the command is not +/// supported by the device. +fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) -> AppConfig { + let Ok(raw) = transport.rs_key_config_read(RSKEY_CFG_TARGET_PHY) else { + log::info!("RS-Key FIDO config read unavailable (transport error)"); + return config; + }; + + if raw.len() <= 1 { + log::info!( + "RS-Key FIDO config read unavailable (response len={}, likely pre-v0.3.1 firmware)", + raw.len() + ); + return config; + } + + let data = if raw.first().copied() == Some(raw.len().saturating_sub(1) as u8) { + &raw[1..] + } else { + &raw[..] + }; + + let mut i = 0; + while i + 1 < data.len() { + if i + 2 > data.len() { + break; + } + let tag_byte = data[i]; + let len = data[i + 1] as usize; + i += 2; + if i + len > data.len() { + break; + } + let val = &data[i..i + len]; + + match tag_byte { + RSKEY_PHY_TAG_VIDPID if val.len() == 4 => { + config.vid = format!("{:04X}", u16::from_be_bytes([val[0], val[1]])); + config.pid = format!("{:04X}", u16::from_be_bytes([val[2], val[3]])); + } + RSKEY_PHY_TAG_LED_GPIO if !val.is_empty() => { + config.led_gpio = val[0]; + } + RSKEY_PHY_TAG_LED_BRIGHTNESS if !val.is_empty() => { + config.led_brightness = val[0]; + } + RSKEY_PHY_TAG_PRESENCE_TIMEOUT if !val.is_empty() => { + config.touch_timeout = val[0]; + } + RSKEY_PHY_TAG_USB_PRODUCT => { + let s = std::str::from_utf8(val) + .unwrap_or("") + .trim_matches(char::from(0)); + config.product_name = s.to_string(); + } + RSKEY_PHY_TAG_OPTS if val.len() >= 2 => { + let opts = u16::from_be_bytes([val[0], val[1]]); + config.led_dimmable = opts & RSKEY_OPT_DIMMABLE != 0; + config.power_cycle_on_reset = opts & RSKEY_OPT_DISABLE_POWER_RESET == 0; + config.led_steady = opts & RSKEY_OPT_LED_STEADY != 0; + } + RSKEY_PHY_TAG_CURVES if val.len() == 4 => { + config.raw_curves_mask = Some(u32::from_be_bytes([val[0], val[1], val[2], val[3]])); + } + RSKEY_PHY_TAG_LED_DRIVER if !val.is_empty() => { + config.led_driver = Some(val[0]); + } + RSKEY_PHY_TAG_LED_ORDER if !val.is_empty() => { + config.led_order = Some(val[0]); + } + RSKEY_PHY_TAG_LED_NUM if !val.is_empty() => { + config.led_num = Some(val[0]); + } + RSKEY_PHY_TAG_ENABLED_USB_ITF if !val.is_empty() => { + config.enabled_usb_itf = Some(val[0]); + } + _ => {} + } + i += len; + } + + config +} + +/// Build a PHY TLV blob from `AppConfigInput` for RS-Key CONFIG_WRITE. +/// +/// The TLV format matches the Rescue PHY record and is sent as-is +/// to the RS-Key 0x41 CONFIG_WRITE handler. +fn build_rskey_phy_tlv(config: &AppConfigInput) -> Vec { + let mut tlv = Vec::new(); + + if let (Some(vid_str), Some(pid_str)) = (&config.vid, &config.pid) + && let (Ok(vid), Ok(pid)) = ( + u16::from_str_radix(vid_str, 16), + u16::from_str_radix(pid_str, 16), + ) + { + tlv.push(RSKEY_PHY_TAG_VIDPID); + tlv.push(0x04); + tlv.extend_from_slice(&vid.to_be_bytes()); + tlv.extend_from_slice(&pid.to_be_bytes()); + } + + if let Some(val) = config.led_gpio { + tlv.push(RSKEY_PHY_TAG_LED_GPIO); + tlv.push(0x01); + tlv.push(val); + } + + if let Some(val) = config.led_brightness { + tlv.push(RSKEY_PHY_TAG_LED_BRIGHTNESS); + tlv.push(0x01); + tlv.push(val); + } + + if let (Some(dim), Some(cycle), Some(steady)) = ( + config.led_dimmable, + config.power_cycle_on_reset, + config.led_steady, + ) { + let mut opts = 0u16; + if dim { + opts |= RSKEY_OPT_DIMMABLE; + } + if !cycle { + opts |= RSKEY_OPT_DISABLE_POWER_RESET; + } + if steady { + opts |= RSKEY_OPT_LED_STEADY; + } + tlv.push(RSKEY_PHY_TAG_OPTS); + tlv.push(0x02); + tlv.extend_from_slice(&opts.to_be_bytes()); + } + + if let Some(val) = config.touch_timeout { + tlv.push(RSKEY_PHY_TAG_PRESENCE_TIMEOUT); + tlv.push(0x01); + tlv.push(val); + } + + if let Some(name) = config.product_name.as_deref().filter(|n| !n.is_empty()) { + let bytes = name.as_bytes(); + tlv.push(RSKEY_PHY_TAG_USB_PRODUCT); + tlv.push((bytes.len() + 1) as u8); + tlv.extend_from_slice(bytes); + tlv.push(0x00); + } + + if config.enable_secp256k1.is_some() || config.raw_curves_mask.is_some() { + let mut mask = config.raw_curves_mask.unwrap_or(0); + if let Some(enabled) = config.enable_secp256k1 { + if enabled { + mask |= 0x08; // SECP256K1 + } else { + mask &= !0x08u32; + } + } + tlv.push(RSKEY_PHY_TAG_CURVES); + tlv.push(0x04); + tlv.extend_from_slice(&mask.to_be_bytes()); + } + + if let Some(val) = config.led_driver { + tlv.push(RSKEY_PHY_TAG_LED_DRIVER); + tlv.push(0x01); + tlv.push(val); + } + + if let Some(val) = config.led_order { + tlv.push(RSKEY_PHY_TAG_LED_ORDER); + tlv.push(0x01); + tlv.push(val); + } + + if let Some(val) = config.enabled_usb_itf { + tlv.push(RSKEY_PHY_TAG_ENABLED_USB_ITF); + tlv.push(0x01); + tlv.push(val); + } + + if let Some(val) = config.led_num { + tlv.push(RSKEY_PHY_TAG_LED_NUM); + tlv.push(0x01); + tlv.push(val); + } + + tlv +} + +/// Write PHY config to an RS-Key via CTAPHID 0x41 CONFIG_WRITE. +fn write_rskey_config( + transport: &HidTransport, + config: &AppConfigInput, + pin: &str, +) -> Result { + let tlv = build_rskey_phy_tlv(config); + if tlv.is_empty() { + return Ok("No RS-Key configuration changes were needed.".to_string()); + } + + // Probe: CONFIG_READ (0x41 subcommand 0x0D) is ungated and confirms + // the device supports the 0x41 CONFIG_WRITE/CONFIG_READ commands + // (RS-Key v0.3.1+). Pre-v0.3.1 devices return a CTAP error byte, which + // we detect as a response with len <= 1. + let cfg_read_resp = transport.rs_key_config_read(RSKEY_CFG_TARGET_PHY)?; + if cfg_read_resp.len() <= 1 { + return Err(PFError::Device( + "This RS-Key firmware does not support FIDO configuration. \ + Please use Rescue mode (CCID/PCSC) or update to RS-Key v0.3.1+." + .into(), + )); + } + + let pin_token = transport + .get_pin_token_with_permission(pin, PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, None) + .or_else(|e| { + log::warn!( + "Failed to get PIN token with ACFG permission: {}. Falling back.", + e + ); + transport.get_pin_token(pin) + })?; + + transport.rs_key_config_write(&pin_token, RSKEY_CFG_TARGET_PHY, &tlv)?; + + Ok( + "Configuration updated successfully! Unplug and re-plug the device to apply changes." + .to_string(), + ) +} + +/// Write device configuration over the FIDO HID transport. +/// +/// Applies the partial [`AppConfigInput`] to the authenticator using +/// the appropriate vendor or standard config command path for the +/// detected firmware type. Requires the PIN for config write operations. pub fn write_config(config: AppConfigInput, pin: Option) -> Result { log::info!("Starting FIDO write_config..."); @@ -830,19 +1153,46 @@ pub fn write_config(config: AppConfigInput, pin: Option) -> Result write_rskey_config(&transport, &config, pin_val), + FirmwareType::PicoFido if firmware.supports_fido_config_write() => { + write_legacy_hardware_config(&transport, &config, pin_val) + } + _ => { + log::error!( + "write_config called on unsupported firmware (pico-fido requires rescue mode)" + ); + Err(PFError::Device( + "Hardware configuration over FIDO is not supported on this device. \ + Use rescue mode (CCID/PCSC) instead." + .into(), + )) + } + } } fn is_empty_config_input(config: &AppConfigInput) -> bool { @@ -861,10 +1211,12 @@ fn is_empty_config_input(config: &AppConfigInput) -> bool { fn validate_fido_config_changes( config: &AppConfigInput, - supports_legacy_hardware_config: bool, + firmware: &AnyFirmware, ) -> Result<(), PFError> { - if !supports_legacy_hardware_config - && (config.vid.is_some() + let allow_write = firmware.supports_legacy_fido_hardware_config() + || firmware.supports_rs_key_vendor_command(); + if !allow_write { + if config.vid.is_some() || config.pid.is_some() || config.product_name.is_some() || config.led_gpio.is_some() @@ -874,31 +1226,18 @@ fn validate_fido_config_changes( || config.led_dimmable.is_some() || config.power_cycle_on_reset.is_some() || config.led_steady.is_some() - || config.enable_secp256k1.is_some()) - { - return Err(PFError::Device( - "Pico-FIDO 7.6 does not support hardware configuration over FIDO-only mode. Use rescue mode to change VID/PID, product name, LED, touch timeout, power/reset, or curve settings.".into(), - )); - } - - if supports_legacy_hardware_config { - if config.product_name.is_some() - || config.touch_timeout.is_some() - || config.led_driver.is_some() || config.enable_secp256k1.is_some() { return Err(PFError::Device( - "This firmware only supports VID/PID, LED GPIO, LED brightness, and basic LED/power options over FIDO. Use rescue mode for product name, touch timeout, LED driver, or curve settings.".into(), - )); - } - - if config.vid.is_some() != config.pid.is_some() { - return Err(PFError::Device( - "VID and PID must be changed together in FIDO mode.".into(), + "This firmware does not support hardware configuration over FIDO. \ + Use rescue mode for hardware changes." + .into(), )); } + return Ok(()); } + // RS-Key: 0x41 CONFIG_WRITE supports the full PHY TLV — no field restrictions. Ok(()) } @@ -976,6 +1315,16 @@ fn write_legacy_hardware_config( )?; } + if config.touch_timeout.is_some() + || config.led_driver.is_some() + || config.enable_secp256k1.is_some() + { + log::warn!( + "Legacy hardware config does not support touch_timeout, led_driver, or enable_secp256k1 \ + fields. These were silently ignored. If your device supports them, file a feature request." + ); + } + Ok("Configuration updated successfully! Unplug and re-plug the device to apply VID/PID changes.".to_string()) } @@ -1107,6 +1456,138 @@ pub(crate) fn get_enterprise_attestation_csr() -> Result { Ok(pem) } +// ── RS-Key FIDO LED config (CONFIG_READ/WRITE target 0x02) ────────────── + +/// RS-Key LED config block length: `[steady(1), (effect, color, brightness, speed) × 4]` +const RSKEY_LED_CONF_LEN: usize = 17; + +/// Read the LED configuration from an RS-Key over FIDO. +/// +/// Uses CTAPHID 0x41 CONFIG_READ (target 0x02) to retrieve the +/// 17-byte LED config block. Maps the device status fields +/// (effect, color, brightness, speed) into the compatible +/// [`LedStatusConfig`] type, keeping only color and brightness +/// for backward compatibility with the Rescue LED UI. +pub(crate) fn read_rskey_led_config(transport: &HidTransport) -> Result { + let raw = transport.rs_key_config_read(RSKEY_CFG_TARGET_LED)?; + if raw.len() < RSKEY_LED_CONF_LEN { + return Err(PFError::Device(format!( + "LED config response too short: {} bytes (expected {})", + raw.len(), + RSKEY_LED_CONF_LEN, + ))); + } + + let data = if raw.first().copied() == Some(raw.len().saturating_sub(1) as u8) { + &raw[1..] + } else { + &raw[..] + }; + + if data.len() < 9 { + return Err(PFError::Device(format!( + "LED config payload too short: {} bytes", + data.len(), + ))); + } + + let steady = data[0] != 0; + let statuses = if data.len() >= RSKEY_LED_CONF_LEN { + // Full block: [steady, (effect, color, brightness, speed) × N] + let mut s = [(0u8, 0u8); 4]; + for (i, slot) in s.iter_mut().enumerate() { + *slot = (data[2 + 4 * i], data[3 + 4 * i]); // color, brightness + } + s + } else { + // Legacy 9-byte block: [steady, (color, brightness) × N] + let mut s = [(0u8, 0u8); 4]; + for (i, slot) in s.iter_mut().enumerate() { + let off = 1 + 2 * i; + if off + 1 < data.len() { + *slot = (data[off], data[off + 1]); + } + } + s + }; + + log::info!( + "RS-Key FIDO LED config: steady={}, statuses={:?}", + steady, + statuses + ); + Ok(LedStatusConfig { steady, statuses }) +} + +/// Write the full LED configuration to an RS-Key over FIDO. +/// +/// Builds a 17-byte config block `[steady, (effect=0, color, brightness, speed=0) × 4]` +/// and sends it via CTAPHID 0x41 CONFIG_WRITE (target 0x02). The firmware applies +/// the new config live — no reboot required. +/// +/// Requires a PIN token with `AUTHENTICATOR_CONFIG` permission. +pub(crate) fn write_rskey_led_config( + transport: &HidTransport, + config: &LedStatusConfig, + pin: &str, +) -> Result { + let mut block = [0u8; RSKEY_LED_CONF_LEN]; + block[0] = if config.steady { 0x01 } else { 0x00 }; + for (i, &(color, brightness)) in config.statuses.iter().enumerate() { + let off = 1 + 4 * i; + block[off] = 0x00; // effect = solid + block[off + 1] = color & 0x07; + block[off + 2] = brightness; + block[off + 3] = 0x00; // speed = default + } + + let pin_token = transport.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + )?; + + transport.rs_key_config_write(&pin_token, RSKEY_CFG_TARGET_LED, &block)?; + + Ok("LED configuration updated successfully.".to_string()) +} + +// ── RS-Key FIDO Management / DEV_CONF (CONFIG_WRITE target 0x00) ──────── + +/// MGMT TLV tag for USB enabled interfaces. +const FIDO_MGMT_TAG_USB_ENABLED: u8 = 0x03; + +/// Write the USB application enabled-mask to an RS-Key over FIDO. +/// +/// Builds a TLV blob (`tag 0x03, len 2, [enabled_be]`) matching the +/// CCID Management applet WRITE CONFIG format, and sends it via +/// CTAPHID 0x41 CONFIG_WRITE (target 0x00 = DEV_CONF). The firmware +/// persists the mask to `EF_DEV_CONF`; changes apply after a re-plug. +/// +/// Requires a PIN token with `AUTHENTICATOR_CONFIG` permission. +pub(crate) fn write_rskey_dev_config( + transport: &HidTransport, + enabled_mask: u16, + pin: &str, +) -> Result { + let tlv = [ + FIDO_MGMT_TAG_USB_ENABLED, + 0x02, + (enabled_mask >> 8) as u8, + (enabled_mask & 0xFF) as u8, + ]; + + let pin_token = transport.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + )?; + + transport.rs_key_config_write(&pin_token, RSKEY_CFG_TARGET_DEV_CONF, &tlv)?; + + Ok("USB applications updated. Unplug and re-plug the device to apply changes.".to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -1129,6 +1610,7 @@ mod tests { raw_curves_mask: None, led_order: None, enabled_usb_itf: None, + led_num: None, } } @@ -1266,25 +1748,34 @@ mod tests { #[test] fn test_firmware_supports_legacy_fido_hardware_config() { - assert!(firmware_supports_legacy_fido_hardware_config("6.6")); - assert!(firmware_supports_legacy_fido_hardware_config("7.0")); - assert!(firmware_supports_legacy_fido_hardware_config("7.2")); - assert!(!firmware_supports_legacy_fido_hardware_config("7.4")); - assert!(!firmware_supports_legacy_fido_hardware_config("7.6")); - assert!(!firmware_supports_legacy_fido_hardware_config("Unknown")); + let check = |v: &str| -> bool { + let ver = match crate::hal::common::FirmwareVersion::parse(v) { + Some(ver) => ver, + None => return false, + }; + ver.major < 7 || (ver.major == 7 && ver.minor <= 2) + }; + assert!(check("6.6")); + assert!(check("7.0")); + assert!(check("7.2")); + assert!(!check("7.4")); + assert!(!check("7.6")); + assert!(!check("Unknown")); } #[test] fn test_validate_fido_config_changes_accepts_noop_without_legacy_support() { - assert!(validate_fido_config_changes(&empty_config_input(), false).is_ok()); + let fw = AnyFirmware::new(FirmwareType::PicoFido, "7.6"); + assert!(validate_fido_config_changes(&empty_config_input(), &fw).is_ok()); } #[test] fn test_validate_fido_config_changes_rejects_hardware_update_without_legacy_support() { let mut config = empty_config_input(); config.led_gpio = Some(25); + let fw = AnyFirmware::new(FirmwareType::PicoFido, "7.6"); - let err = validate_fido_config_changes(&config, false) + let err = validate_fido_config_changes(&config, &fw) .unwrap_err() .to_string(); @@ -1302,30 +1793,174 @@ mod tests { config.power_cycle_on_reset = Some(false); config.led_steady = Some(true); - assert!(validate_fido_config_changes(&config, true).is_ok()); + let fw = AnyFirmware::new_with_legacy(FirmwareType::PicoFido, "7.6", true); + assert!(validate_fido_config_changes(&config, &fw).is_ok()); } #[test] - fn test_validate_fido_config_changes_rejects_legacy_unsupported_update() { + fn test_validate_fido_config_changes_accepts_all_common_fields_in_legacy_mode() { + // With legacy vendor support, all fields are accepted — no LkOne-style + // VID/PID-only restriction exists for the CONFIG_WRITE path. let mut config = empty_config_input(); + config.led_gpio = Some(25); config.product_name = Some("Pico Key".to_string()); + config.touch_timeout = Some(30); - let err = validate_fido_config_changes(&config, true) - .unwrap_err() - .to_string(); - - assert!(err.contains("only supports VID/PID")); + let fw = AnyFirmware::new_with_legacy(FirmwareType::PicoFido, "7.6", true); + assert!(validate_fido_config_changes(&config, &fw).is_ok()); } #[test] - fn test_validate_fido_config_changes_requires_vid_pid_pair_for_legacy() { + fn test_validate_fido_config_changes_accepts_rskey_all_fields() { + // RS-Key accepts all fields via CONFIG_WRITE TLV. let mut config = empty_config_input(); config.vid = Some("FEFF".to_string()); + config.power_cycle_on_reset = Some(false); + config.led_steady = Some(true); - let err = validate_fido_config_changes(&config, true) - .unwrap_err() - .to_string(); + let fw = AnyFirmware::new(FirmwareType::RSKey, "5.7"); + assert!(validate_fido_config_changes(&config, &fw).is_ok()); + } - assert!(err.contains("VID and PID")); + #[test] + fn test_parse_get_info_rskey_style() { + // RS-Key GetInfo has a different AAGUID, may include PQC algorithms, + // and reports firmware at a different key. + let mut map = BTreeMap::new(); + map.insert( + Value::Integer(0x01), + Value::Array(vec![ + Value::Text("U2F_V2".into()), + Value::Text("FIDO_2_0".into()), + Value::Text("FIDO_2_1".into()), + ]), + ); + // RS-Key AAGUID + map.insert( + Value::Integer(0x03), + Value::Bytes(vec![ + 0x24, 0x79, 0xC7, 0xBF, 0x6B, 0x30, 0x56, 0x83, 0x9E, 0xC8, 0x0E, 0x81, 0x71, 0xA9, + 0x18, 0xB7, + ]), + ); + map.insert(Value::Integer(0x05), Value::Integer(1200)); + map.insert( + Value::Integer(0x06), + Value::Array(vec![Value::Integer(1), Value::Integer(2)]), + ); + map.insert(Value::Integer(0x0D), Value::Integer(4)); + + // RS-Key firmware version (5.7.4 encoded as (5<<8)|7 = 0x0507) + map.insert(Value::Integer(0x0E), Value::Integer(0x050704)); + + // Algorithms list including PQC + let es256 = BTreeMap::from([(Value::Text("alg".into()), Value::Integer(-7))]); + let eddsa = BTreeMap::from([(Value::Text("alg".into()), Value::Integer(-8))]); + let pqc = BTreeMap::from([(Value::Text("alg".into()), Value::Integer(-48))]); + map.insert( + Value::Integer(0x0A), + Value::Array(vec![Value::Map(es256), Value::Map(eddsa), Value::Map(pqc)]), + ); + + // RS-Key options + let mut opts = BTreeMap::new(); + opts.insert(Value::Text("rk".into()), Value::Bool(true)); + opts.insert(Value::Text("up".into()), Value::Bool(true)); + opts.insert(Value::Text("clientPin".into()), Value::Bool(false)); + opts.insert(Value::Text("credMgmt".into()), Value::Bool(true)); + opts.insert(Value::Text("authnrCfg".into()), Value::Bool(true)); + map.insert(Value::Integer(0x04), Value::Map(opts)); + + let info = parse_fido_get_info(&Value::Map(map)).unwrap(); + + assert_eq!(info.aaguid, "2479C7BF6B3056839EC80E8171A918B7"); + assert_eq!(info.firmware_version, "5.7.4"); + assert_eq!(info.versions, vec!["U2F_V2", "FIDO_2_0", "FIDO_2_1"]); + assert_eq!(info.algorithms, vec!["ES256", "EdDSA", "ML-DSA-44"]); + assert_eq!(info.min_pin_length, 4); + assert!(info.options.get("rk") == Some(&true)); + assert!(info.options.get("credMgmt") == Some(&true)); + assert!(info.options.get("clientPin") == Some(&false)); + } + + #[test] + fn test_parse_get_info_empty_returns_default() { + let result = parse_fido_get_info(&Value::Map(BTreeMap::new())); + assert!(result.is_ok()); + assert!(result.unwrap().versions.is_empty()); + } + + #[test] + fn test_parse_get_info_skips_unknown_keys() { + let mut map = BTreeMap::new(); + map.insert( + Value::Integer(0x01), + Value::Array(vec![Value::Text("FIDO_2_1".into())]), + ); + map.insert(Value::Integer(0x03), Value::Bytes(vec![0x89; 16])); + map.insert(Value::Integer(0x05), Value::Integer(1024)); + + // Unknown keys should be silently skipped + map.insert(Value::Integer(0x10), Value::Integer(999)); + map.insert(Value::Integer(0x11), Value::Integer(888)); + map.insert(Value::Integer(0x12), Value::Integer(777)); + + let info = parse_fido_get_info(&Value::Map(map)).unwrap(); + assert_eq!(info.versions, vec!["FIDO_2_1"]); + assert_eq!(info.max_msg_size, 1024); + } + + #[test] + fn test_parse_get_info_minimal_response() { + let mut map = BTreeMap::new(); + map.insert( + Value::Integer(0x01), + Value::Array(vec![Value::Text("FIDO_2_0".into())]), + ); + + let info = parse_fido_get_info(&Value::Map(map)).unwrap(); + assert_eq!(info.versions, vec!["FIDO_2_0"]); + assert_eq!(info.max_msg_size, 0); + } + + #[test] + fn test_parse_get_info_certification_map_unknown_id_becomes_hex() { + let mut cert_map = BTreeMap::new(); + cert_map.insert(Value::Text("0xDEADBEEFCAFEBABE".into()), Value::Bool(true)); + + let mut map = BTreeMap::new(); + map.insert(Value::Integer(0x15), Value::Map(cert_map)); + + let info = parse_fido_get_info(&Value::Map(map)).unwrap(); + assert_eq!(info.certifications.get("0xDEADBEEFCAFEBABE"), Some(&true)); + } + + #[test] + fn test_parse_get_info_management_info_version_fallback() { + // When firmware version in GetInfo is 0.0, management info version + // should be used instead - this is tested via the management info + // parsing, but verify the GetInfo parser handles the edge case. + let mut map = BTreeMap::new(); + map.insert( + Value::Integer(0x01), + Value::Array(vec![Value::Text("FIDO_2_1".into())]), + ); + map.insert(Value::Integer(0x03), Value::Bytes(vec![0x89; 16])); + // firmware_version = 0x0000 -> would become "0.0" + map.insert(Value::Integer(0x0E), Value::Integer(0x0000)); + + let info = parse_fido_get_info(&Value::Map(map)).unwrap(); + assert_eq!(info.firmware_version, "0.0"); + } + + #[test] + fn test_is_empty_config_input_works() { + assert!(is_empty_config_input(&empty_config_input())); + let mut c = empty_config_input(); + c.led_gpio = Some(25); + assert!(!is_empty_config_input(&c)); + let mut c = empty_config_input(); + c.vid = Some("FEFF".to_string()); + assert!(!is_empty_config_input(&c)); } } diff --git a/src/hal/fido/hid.rs b/src/hal/fido/ops.rs similarity index 72% rename from src/hal/fido/hid.rs rename to src/hal/fido/ops.rs index ab375be..6767bce 100644 --- a/src/hal/fido/hid.rs +++ b/src/hal/fido/ops.rs @@ -1,171 +1,19 @@ -//! USB HID transport for CTAP2/FIDO2 communication. +//! Low-level FIDO2 operations implementing the CTAP2 PIN/UV auth protocol, +//! credential management, and firmware-specific vendor commands. //! -//! # What is HID? -//! -//! USB HID (Human Interface Device) is a standard USB device class for input -//! devices like keyboards, mice, and gamepads. HID devices communicate through -//! *reports* — fixed-size packets sent/received on USB endpoints. The OS -//! auto-detects HID devices without requiring custom drivers, making it ideal -//! for FIDO2 security keys that need to work across platforms. -//! -//! # What is CTAPHID? -//! -//! CTAPHID is the [CTAP2] transport binding for USB HID. It layers the CTAP2 -//! protocol on top of HID reports, allowing FIDO2 authenticators to -//! communicate with hosts through the standard HID driver stack. The -//! specification is defined in [CTAP2 §11.2](https://fidoalliance.org/specs/fido-v2.3-ps-20260226/fido-client-to-authenticator-protocol-v2.3-ps-20260226.html#usb-human-interface-device-hid). -//! -//! # Framing protocol -//! -//! CTAPHID uses 64-byte HID reports. Messages that exceed 64 bytes are split -//! across multiple packets: -//! -//! ```text -//! Init Packet (64 bytes): -//! CID(4) | CMD(1) | BCNT_HI(1) | BCNT_LO(1) | payload[..57] -//! -//! Continuation Packets: -//! CID(4) | SEQ(1) | payload[..59] -//! ``` -//! -//! - **CID** (Channel ID): 4-byte identifier negotiated via `CTAPHID_INIT`. -//! Multiplexes multiple logical channels on one HID device. -//! - **CMD**: Command byte (e.g., `0x90` for CBOR, `0x86` for INIT). -//! - **BCNT**: 16-bit big-endian payload length. -//! - **SEQ**: Sequence number for continuation packets (starts at 0). -//! -//! # Channel initialization -//! -//! Before any CTAP2 command can be sent, the host must negotiate a Channel ID: -//! -//! 1. Host sends `CTAPHID_INIT` to the broadcast CID (`0xFFFFFFFF`) with a -//! random 8-byte nonce. -//! 2. Device responds with the same nonce and a newly allocated CID. -//! 3. All subsequent communication uses this CID. -//! -//! This allows multiple CTAP2 sessions to coexist on one device (e.g., two -//! browsers open simultaneously). -//! -//! # Cryptographic operations -//! -//! PIN operations require ECDH key agreement and AES-256-CBC encryption: -//! -//! ```text -//! 1. Host → Device: GetKeyAgreement (returns device's P-256 public key) -//! 2. Host generates ephemeral P-256 key pair -//! 3. Host computes ECDH shared secret → SHA-256(shared_secret) -//! 4. PIN hash encrypted with AES-256-CBC (key = shared_secret, IV = 0) -//! 5. Token decrypted with same key -//! ``` -//! -//! The shared secret is derived as `SHA-256(ECDH_x_coordinate)`. -//! -//! # Firmware compatibility -//! -//! Both [pico-fido] and [RS-Key] implement CTAPHID. This module handles: -//! - Standard CTAP2 commands (GetInfo, MakeCredential, GetAssertion, etc.) -//! - Pico-fido vendor commands (`0xC1`, `0xC2`) for hardware config -//! - RS-Key vendor command (`0x41`) for seed backup and attestation -//! -//! # File structure -//! -//! - [`HidTransport`] — main transport struct; opens HID device, negotiates -//! CID, sends/receives CBOR payloads -//! - [`EnumerateRpResponse`], [`EnumerateCredentialResponse`] — response -//! types for credential management enumeration -//! - PIN methods (`get_pin_token`, `set_pin`, `change_pin`) implement the -//! full ECDH + AES-CBC flow per CTAP2 §11.5.4 -//! - Vendor methods (`send_vendor_config`, `get_enterprise_attestation_csr`) -//! handle pico-fido/RS-Key specific extensions -//! -//! [CTAP2]: https://fidoalliance.org/specs/fido-v2.3-ps-20260226/fido-client-to-authenticator-protocol-v2.3-ps-20260226.html -//! [pico-fido]: https://github.com/polhenarejos/pico-fido -//! [RS-Key]: https://github.com/TheMaxMur/RS-Key +//! The [`FidoOperations`] trait is implemented on [`HidTransport`] and provides +//! the building blocks used by the high-level functions in [`super`]. use cbc::cipher::{Block, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}; -use rand::RngExt; + use ring::{agreement, digest, hmac}; use serde_cbor_2::{Value, from_slice, to_vec}; use std::collections::BTreeMap; -use std::time::Duration; use crate::error::PFError; use crate::hal::fido::constants::*; +use crate::hal::transport::fido::{CTAPHID_CBOR, HidTransport}; -/// Size of a single USB HID report in bytes (CTAP2 §11.2 mandates 64-byte reports). -const HID_REPORT_SIZE: usize = 64; - -/// FIDO Alliance HID Usage Page identifier. -/// -/// Devices advertising this usage page in their HID descriptor are identified -/// as FIDO authenticators by the operating system's HID enumeration. -const HID_USAGE_PAGE_FIDO: u16 = 0xF1D0; - -/// Broadcast Channel ID used for the initial CTAPHID_INIT handshake. -/// -/// The host sends an INIT command to this CID to request a unique Channel ID -/// from the authenticator. All subsequent communication uses the negotiated CID. -const CTAPHID_CID_BROADCAST: u32 = 0xFFFFFFFF; - -/// CTAPHID INIT command byte (0x86). -/// -/// Initiates channel negotiation. The host sends a random 8-byte nonce; the -/// device responds with the same nonce and a newly allocated Channel ID. -const CTAPHID_INIT: u8 = 0x86; - -/// CTAPHID CBOR command byte (0x90). -/// -/// Wraps a CTAP2 CBOR-encoded command or response payload. The payload is -/// fragmented across one init packet and zero or more continuation packets. -pub const CTAPHID_CBOR: u8 = 0x90; - -/// CTAPHID ERROR response byte (0xBF). -/// -/// Indicates the authenticator encountered an error processing the command. -/// The next byte contains the CTAP2 error code. -const CTAPHID_ERROR: u8 = 0xBF; - -/// CTAPHID KEEPALIVE status byte (0xBB). -/// -/// Sent by the authenticator while processing a long-running operation (e.g., -/// MakeCredential with user interaction). The host must continue reading -/// until it receives the final CBOR or ERROR response. -const CTAPHID_KEEPALIVE: u8 = 0xBB; - -/// Default timeout in milliseconds for draining stale HID packets. -const HID_READ_TIMEOUT_MS: i32 = 10; - -/// Timeout in milliseconds for reading the CTAPHID_INIT response during channel negotiation. -const HID_INIT_READ_TIMEOUT_MS: i32 = 100; - -/// Timeout in milliseconds for reading a single HID response packet (excluding keepalives). -const HID_RESP_READ_TIMEOUT_MS: i32 = 2000; - -/// Timeout in milliseconds for reading CTAPHID continuation packets. -const HID_CONT_READ_TIMEOUT_MS: i32 = 500; - -/// Maximum total time in milliseconds allowed for a complete CBOR command/response exchange. -const HID_TOTAL_TIMEOUT_MS: i32 = 5000; - -/// USB HID transport for CTAP2/FIDO2 communication. -/// -/// Wraps a `hidapi::HidDevice` and manages the CTAPHID framing layer: -/// channel negotiation (INIT), multi-packet CBOR send/receive, keepalive -/// handling, and all higher-level CTAP2 operations (PIN, credential management, -/// vendor commands). -/// -/// Created via [`HidTransport::open`], which scans for a device with the FIDO -/// HID Usage Page (0xF1D0) and performs the INIT handshake to obtain a Channel ID. -pub struct HidTransport { - device: hidapi::HidDevice, - cid: u32, - pub vid: u16, - pub pid: u16, - pub product_name: String, -} - -/// Response from enumerating a Relying Party via credential management. -/// /// Returned by [`HidTransport::credential_management_enumerate_rps`]. Each entry /// represents one RP stored on the authenticator. #[derive(Debug, Clone)] @@ -181,410 +29,113 @@ pub struct EnumerateRpResponse { /// Returned by [`HidTransport::credential_management_enumerate_credentials`]. /// Each entry represents one credential (public key) registered under an RP. #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct EnumerateCredentialResponse { pub user: Value, pub credential_id: Value, - #[allow(dead_code)] pub public_key: Value, #[allow(dead_code)] pub total_credentials: Option, } -impl HidTransport { - /// Open the first available FIDO HID device and negotiate a Channel ID. - /// - /// Scans for a device with HID Usage Page `0xF1D0`, opens it, and performs - /// the CTAPHID_INIT handshake. Returns an error if no device is found or - /// the INIT handshake times out. - pub fn open() -> Result { - log::info!("Attempting to open HID transport for FIDO device..."); - let api = hidapi::HidApi::new().map_err(|e| { - log::error!("Failed to initialize HidApi: {}", e); - PFError::Device(format!("Failed to initialize HidApi: {}", e)) - })?; - - // Find device with FIDO Usage Page (0xF1D0) - let info = api - .device_list() - .find(|d| d.usage_page() == HID_USAGE_PAGE_FIDO) - .ok_or_else(|| { - log::warn!("No FIDO device found with Usage Page 0xF1D0."); - PFError::NoDevice - })?; - - log::debug!( - "Found FIDO device: VendorID=0x{:04X}, ProductID=0x{:04X}", - info.vendor_id(), - info.product_id() - ); - - let vid = info.vendor_id(); - let pid = info.product_id(); - let product_name = info - .product_string() - .unwrap_or("Unknown FIDO Device") - .to_string(); - - let device = info.open_device(&api).map_err(|e| { - log::error!("Failed to open HID device: {}", e); - PFError::Device(format!("Failed to open HID device: {}", e)) - })?; - - // Negotiate Channel ID (CID) - let cid = Self::init_channel(&device).map_err(|e| { - log::error!("Failed to negotiate Channel ID: {}", e); - PFError::Device(format!("Failed to negotiate Channel ID: {}", e)) - })?; - - log::info!("HID Transport established successfully. CID: 0x{:08X}", cid); - Ok(Self { - device, - cid, - vid, - pid, - product_name, - }) - } - - /// Negotiate a CTAPHID Channel ID via CTAPHID_INIT. - /// - /// Sends an INIT command to the broadcast CID (`0xFFFFFFFF`) with a random - /// 8-byte nonce, then reads the response to extract the allocated CID. - /// Drains any stale packets before the handshake to avoid confusion. - fn init_channel(device: &hidapi::HidDevice) -> Result { - log::debug!("Initializing CTAPHID channel..."); - - let mut drain_buf = [0u8; HID_REPORT_SIZE]; - while let Ok(n) = device.read_timeout(&mut drain_buf[..], HID_READ_TIMEOUT_MS) { - if n == 0 { - break; - } - log::trace!("Drained stale HID packet: {:02X?}", &drain_buf[0..16]); - } - - let mut nonce = [0u8; 8]; - rand::rng().fill(&mut nonce); - - // Construct Init Packet: [CID(4) | CMD(1) | LEN(2) | NONCE(8)] - let mut report = [0u8; HID_REPORT_SIZE + 1]; // +1 for Report ID (always 0) - report[1..5].copy_from_slice(&CTAPHID_CID_BROADCAST.to_be_bytes()); - report[5] = CTAPHID_INIT; - report[6] = 0; // Len MSB - report[7] = 8; // Len LSB - report[8..16].copy_from_slice(&nonce); - - log::trace!("Sending CTAPHID_INIT broadcast with nonce: {:02X?}", nonce); - device.write(&report[..]).map_err(|e| { - log::error!("Failed to write INIT packet: {}", e); - PFError::Io(format!("Failed to write INIT packet: {}", e)) - })?; - - // Read Response until we find our nonce - let start = std::time::Instant::now(); - while start.elapsed() < Duration::from_secs(1) { - let mut buf = [0u8; HID_REPORT_SIZE]; - if device - .read_timeout(&mut buf[..], HID_INIT_READ_TIMEOUT_MS) - .is_ok() - { - // Check if response matches our broadcast and nonce - if buf[0..4] == CTAPHID_CID_BROADCAST.to_be_bytes() - && buf[4] == CTAPHID_INIT - && buf[7..15] == nonce - { - // New CID is at bytes 16..20 - let new_cid = u32::from_be_bytes([buf[15], buf[16], buf[17], buf[18]]); - log::debug!("Channel negotiation successful. New CID: 0x{:08X}", new_cid); - return Ok(new_cid); - } else { - log::trace!( - "Received ignoreable HID packet during CID negotiation: {:02X?}", - &buf[0..16] - ); - } - } - } - log::error!("Timeout waiting for CTAPHID_INIT response."); - Err(PFError::Device( - "Timeout waiting for FIDO Init response".into(), - )) - } - - /// Send a CTAP2 CBOR command and wait for the response using the default timeout. - /// - /// Convenience wrapper around [`send_cbor_with_timeout`](HidTransport::send_cbor_with_timeout). - pub fn send_cbor(&self, cmd: u8, payload: &[u8]) -> Result, PFError> { - self.send_cbor_with_timeout(cmd, payload, HID_TOTAL_TIMEOUT_MS) - } - - /// Send a CTAP2 CBOR command and wait for the response with a custom timeout. - /// - /// Fragments `payload` into CTAPHID init + continuation packets, then reads - /// and reassembles the response. The `timeout_ms` parameter overrides the - /// default for the read phase (useful for operations that require user interaction). - pub fn send_cbor_with_timeout( +/// Low-level CTAP2 operations implemented on the FIDO HID transport. +/// +/// Each method encodes the appropriate CBOR map, sends it via +/// [`HidTransport::send_cbor`], and parses the response. PIN operations +/// follow the ECDH + AES-256-CBC key-agreement flow defined in +/// CTAP2 §11.5.4. +pub trait FidoOperations { + /// Send a vendor-prototype config sub-command (pico-fido specific). + fn send_vendor_config( &self, - cmd: u8, - payload: &[u8], - timeout_ms: i32, - ) -> Result, PFError> { - self.write_cbor_request(cmd, payload)?; - self.read_cbor_response(cmd, timeout_ms) - } + pin_token: &[u8], + vendor_cmd: VendorConfigCommand, + param: Value, + ) -> Result<(), PFError>; + /// Retrieve the enterprise attestation CSR from the authenticator. + fn get_enterprise_attestation_csr(&self) -> Result, PFError>; + /// Send an `authenticatorConfig` sub-command. + fn send_config( + &self, + sub_cmd: ConfigSubCommand, + pin_token: &[u8], + sub_params: Option, + ) -> Result, PFError>; + /// Enable enterprise attestation via config sub-command. + fn send_config_enable_ea(&self, pin_token: &[u8]) -> Result<(), PFError>; + /// Set the minimum PIN length via config sub-command. + fn send_config_set_min_pin_length( + &self, + pin_token: &[u8], + new_min_pin_length: u8, + ) -> Result<(), PFError>; + /// Retrieve the authenticator's ECDH P-256 public key for PIN token exchange. + fn get_key_agreement(&self) -> Result; + /// Derive a PIN token from the user-supplied PIN. + fn get_pin_token(&self, pin: &str) -> Result, PFError>; + /// Derive a PIN token scoped to specific permissions (e.g. credential management). + fn get_pin_token_with_permission( + &self, + pin: &str, + permissions: PinUvAuthTokenPermissions, + rp_id: Option, + ) -> Result, PFError>; + /// Set a new PIN on the authenticator. + fn set_pin(&self, new_pin: &str) -> Result<(), PFError>; + /// Change an existing PIN on the authenticator. + fn change_pin(&self, current_pin: &str, new_pin: &str) -> Result<(), PFError>; + /// Compute a pinUvAuthToken signature for an `authenticatorConfig` sub-command. + fn sign_config_command( + &self, + pin_token: &[u8], + sub_cmd: u8, + sub_params_bytes: &[u8], + ) -> Vec; + /// Encode an ECDH public key as a COSE_Key map (used in PIN exchanges). + fn encode_cose_key(&self, x: &[u8], y: &[u8]) -> Vec; + /// Build the CBOR map for a `clientPin` sub-command. + fn encode_client_pin_params( + &self, + sub_cmd: ClientPinSubCommand, + cose_key_bytes: &[u8], + pin_hash_enc: &[u8], + permissions: Option, + rp_id: Option, + ) -> Vec; + /// Enumerate all relying parties stored on the authenticator. + fn credential_management_enumerate_rps( + &self, + pin: &str, + ) -> Result, PFError>; + /// Enumerate all credentials for a given relying party. + fn credential_management_enumerate_credentials( + &self, + pin: &str, + rp_id_hash: &[u8], + ) -> Result, PFError>; + /// Delete a credential from the authenticator. + fn credential_management_delete_credential( + &self, + pin: &str, + credential_id_map: Value, + ) -> Result<(), PFError>; + /// Read RS-Key configuration via the 0x41 CONFIG_READ vendor command. + fn rs_key_config_read(&self, target: u8) -> Result, PFError>; + /// Write RS-Key configuration via the 0x41 CONFIG_WRITE vendor command. + fn rs_key_config_write(&self, pin_token: &[u8], target: u8, blob: &[u8]) + -> Result<(), PFError>; + /// Compute a pinUvAuthToken signature for a credential management sub-command. + fn sign_credential_mgmt_command( + &self, + pin_token: &[u8], + sub_cmd: u8, + sub_params_bytes: Option<&[u8]>, + ) -> Vec; +} - /// Send a CTAP2 CBOR command and return the raw HID response without status-byte parsing. - /// - /// Unlike [`send_cbor`](HidTransport::send_cbor), this does not check the CTAP status byte - /// or strip it from the response. Useful for vendor commands that return non-standard payloads. - pub fn send_raw(&self, cmd: u8, payload: &[u8]) -> Result, PFError> { - self.write_cbor_request(cmd, payload)?; - self.read_hid_response(cmd, HID_TOTAL_TIMEOUT_MS) - } - - /// Send the CTAP authenticatorReset command (0x07). - /// - /// Resets the authenticator to its factory state: all credentials, PINs, - /// and configuration are erased. Uses a 30-second timeout to allow for - /// any required user interaction (e.g., touch confirmation). - pub fn reset(&self) -> Result<(), PFError> { - log::info!("Sending CTAP authenticatorReset (0x07)..."); - self.write_cbor_request(CTAPHID_CBOR, &[0x07])?; - self.read_cbor_response(CTAPHID_CBOR, 30_000)?; - Ok(()) - } - - /// Fragment and write a CTAPHID request to the device. - /// - /// Encodes the command byte and payload into a CTAPHID init packet followed - /// by zero or more continuation packets, then writes each 65-byte HID report - /// (1 byte Report ID + 64 bytes payload) to the device. - fn write_cbor_request(&self, cmd: u8, payload: &[u8]) -> Result<(), PFError> { - log::debug!( - "Sending CBOR Command: 0x{:02X}, Payload Size: {} bytes", - cmd, - payload.len() - ); - - let total_len = payload.len(); - let mut sent = 0; - let mut sequence = 0u8; - - // 1. Init Packet - let mut report = [0u8; HID_REPORT_SIZE + 1]; - report[1..5].copy_from_slice(&self.cid.to_be_bytes()); - report[5] = cmd; - report[6] = (total_len >> 8) as u8; - report[7] = (total_len & 0xFF) as u8; - - let to_copy = std::cmp::min(total_len, HID_REPORT_SIZE - 7); - report[8..8 + to_copy].copy_from_slice(&payload[0..to_copy]); - sent += to_copy; - - // log::trace!("Writing Init Packet (Sent: {}/{})", sent, total_len); - if let Err(e) = self.device.write(&report[..]) { - log::error!("Failed to write initial HID packet: {}", e); - return Err(PFError::Io(format!( - "Failed to write initial HID packet: {}", - e, - ))); - } else { - log::trace!("Successfully sent initial HID packet"); - } - - // 2. Continuation Packets - while sent < total_len { - let mut report = [0u8; HID_REPORT_SIZE + 1]; - report[1..5].copy_from_slice(&self.cid.to_be_bytes()); - report[5] = 0x7F & sequence; // SEQ - sequence += 1; - - let to_copy = std::cmp::min(total_len - sent, HID_REPORT_SIZE - 5); - report[6..6 + to_copy].copy_from_slice(&payload[sent..sent + to_copy]); - sent += to_copy; - - // log::trace!("Writing Cont Packet Seq {} (Sent: {}/{})", sequence - 1, sent, total_len); - if let Err(e) = self.device.write(&report[..]) { - log::error!( - "Failed to write continuation HID packet (Seq {}): {}", - sequence - 1, - e - ); - return Err(PFError::Io(format!( - "Failed to write continuation HID packet: {}", - e, - ))); - } else { - log::trace!( - "Successfully sent continuation HID packet (Seq {})", - sequence - 1 - ); - } - } - - Ok(()) - } - - /// Read a CTAPHID response and verify the CTAP status byte. - /// - /// Delegates to [`read_hid_response`](HidTransport::read_hid_response) for packet - /// reassembly, then checks the first byte for a non-zero CTAP status code and - /// strips it before returning the payload. - fn read_cbor_response(&self, cmd: u8, timeout_ms: i32) -> Result, PFError> { - let response_data = self.read_hid_response(cmd, timeout_ms)?; - - // Check CTAP Status Byte (First byte of payload) - if response_data.is_empty() { - log::error!("Device sent empty payload response."); - return Err(PFError::Device("Empty response".into())); - } - let status = response_data[0]; - if status != 0x00 { - log::error!("FIDO Operation returned failure status: 0x{:02X}", status); - return Err(PFError::Device(format!( - "FIDO Operation Failed with Status: 0x{:02X}", - status - ))); - } - - log::debug!( - "Command 0x{:02X} successful. Response payload len: {}", - cmd, - response_data.len() - 1 - ); - // Return payload without status byte - Ok(response_data[1..].to_vec()) - } - - /// Read and reassemble a CTAPHID response from the device. - /// - /// Handles the full CTAPHID receive flow: - /// 1. Reads the init packet while skipping KEEPALIVE and mismatched-CID packets. - /// 2. Validates the command byte matches the expected response. - /// 3. Reads continuation packets in sequence order until the full payload is received. - /// 4. Enforces the `timeout_ms` deadline across the entire read. - fn read_hid_response(&self, cmd: u8, timeout_ms: i32) -> Result, PFError> { - log::debug!("Waiting for response..."); - - let mut buf = [0u8; HID_REPORT_SIZE]; - let mut response_data = Vec::new(); - let expected_len: usize; - let mut read_len = 0; - let mut last_seq = 0; - - let start_time = std::time::Instant::now(); - let timeout_duration = std::time::Duration::from_millis(timeout_ms as u64); - - // 1. Read First Packet (Loop to handle Keepalives) - loop { - if start_time.elapsed() > timeout_duration { - log::error!("Timeout waiting for device response (Keepalive limit exceeded)"); - return Err(PFError::Device( - "Timeout waiting for device response (Keepalive limit exceeded)".into(), - )); - } - - if let Err(e) = self - .device - .read_timeout(&mut buf[..], HID_RESP_READ_TIMEOUT_MS) - { - log::error!("Timeout reading response packet: {}", e); - return Err(PFError::Io(format!( - "Timeout reading response packet: {}", - e - ))); - } - - // Check CID mismatch - if u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) != self.cid { - log::warn!("Received packet from different CID, ignoring..."); - continue; - } - - // Check for KEEPALIVE (0xBB) - if buf[4] == CTAPHID_KEEPALIVE { - let status = buf[5]; // Keepalive status byte - log::debug!( - "Device sent KEEPALIVE (Status: 0x{:02X}), waiting...", - status - ); - continue; // Go back to start of loop and read again - } - - // If we are here, it's a real response - break; - } - - if buf[4] == CTAPHID_ERROR { - log::error!("Device returned CTAP Error code: 0x{:02X}", buf[5]); - return Err(PFError::Device(format!( - "Device returned CTAP Error: 0x{:02X}", - buf[5], - ))); - } else { - log::trace!("Packet received is not a CTAP Error"); - } - - if buf[4] == cmd { - expected_len = u16::from_be_bytes([buf[5], buf[6]]) as usize; - let in_pkt = std::cmp::min(expected_len, HID_REPORT_SIZE - 7); - response_data.extend_from_slice(&buf[7..7 + in_pkt]); - read_len += in_pkt; - // log::trace!("Received Init Response. Expecting {} bytes total.", expected_len); - } else { - log::error!( - "Unexpected command response: 0x{:02X} (Expected 0x{:02X})", - buf[4], - cmd - ); - return Err(PFError::Device(format!( - "Unexpected command response: 0x{:02X} (Expected 0x{:02X})", - buf[4], cmd - ))); - } - - // 2. Read Continuation Packets - while read_len < expected_len { - if let Err(e) = self - .device - .read_timeout(&mut buf[..], HID_CONT_READ_TIMEOUT_MS) - { - log::error!("Timeout reading continuation packet: {}", e); - return Err(PFError::Io(format!( - "Timeout reading continuation packet: {}", - e - ))); - } - - if u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) != self.cid { - continue; // Ignore packets from other channels - } - - let seq = buf[4]; - if seq != last_seq { - log::error!( - "Sequence mismatch in response. Expected {}, got {}", - last_seq, - seq - ); - return Err(PFError::Device("Sequence mismatch".into())); - } - last_seq += 1; - - let in_pkt = std::cmp::min(expected_len - read_len, HID_REPORT_SIZE - 5); - response_data.extend_from_slice(&buf[5..5 + in_pkt]); - read_len += in_pkt; - } - - Ok(response_data) - } - - /// Send a pico-fido vendor-specific authenticatorConfig command. - /// - /// Wraps the vendor command ID and parameter into the VendorPrototype - /// sub-command structure, signs it with the PIN token, and sends it as - /// a CTAP Config command. The parameter value type (bytes, integer, or text) +impl FidoOperations for HidTransport { /// determines which CBOR key (0x02/0x03/0x04) is used. - pub fn send_vendor_config( + fn send_vendor_config( &self, pin_token: &[u8], vendor_cmd: VendorConfigCommand, @@ -661,7 +212,7 @@ impl HidTransport { /// /// This calls the pico-fido enterprise attestation vendor command to generate a /// Certificate Signing Request (CSR) for the device's attestation key. - pub fn get_enterprise_attestation_csr(&self) -> Result, PFError> { + fn get_enterprise_attestation_csr(&self) -> Result, PFError> { log::debug!("Requesting Enterprise Attestation CSR (CTAP_VENDOR_EA)..."); let mut req = BTreeMap::new(); @@ -728,7 +279,7 @@ impl HidTransport { /// /// Builds the authenticatorConfig CBOR map with keys in ascending order, signs /// it with the PIN token, and sends it as a CTAP Config command. - pub fn send_config( + fn send_config( &self, sub_cmd: ConfigSubCommand, pin_token: &[u8], @@ -779,7 +330,7 @@ impl HidTransport { /// Calls the EnableEnterpriseAttestation sub-command (0x01) via [`send_config`](HidTransport::send_config). /// Enterprise attestation allows RPs to receive a per-device attestation certificate /// during MakeCredential, enabling enterprise device identification. - pub fn send_config_enable_ea(&self, pin_token: &[u8]) -> Result<(), PFError> { + fn send_config_enable_ea(&self, pin_token: &[u8]) -> Result<(), PFError> { log::debug!("Sending Enterprise Attestation enable config command..."); match self.send_config( ConfigSubCommand::EnableEnterpriseAttestation, @@ -806,7 +357,7 @@ impl HidTransport { /// Calls the SetMinPinLength sub-command (0x03) via [`send_config`](HidTransport::send_config). /// The minimum PIN length can only be increased; attempting to decrease it returns /// `PIN_POLICY_VIOLATION` (0x37). A device reset is required to lower the minimum. - pub fn send_config_set_min_pin_length( + fn send_config_set_min_pin_length( &self, pin_token: &[u8], new_min_pin_length: u8, @@ -856,7 +407,7 @@ impl HidTransport { /// Sends a `getClientPin` command with `getKeyAgreement` sub-command (0x02). /// The returned COSE Key contains the authenticator's ephemeral public key /// (x and y coordinates) used for ECDH key agreement in PIN operations. - pub fn get_key_agreement(&self) -> Result { + fn get_key_agreement(&self) -> Result { let mut map = BTreeMap::new(); map.insert( Value::Integer(ClientPinParam::PinUvAuthProtocol as i128), @@ -896,7 +447,7 @@ impl HidTransport { /// 3. Performs ECDH and derives `SHA-256(shared_secret)`. /// 4. Encrypts the first 16 bytes of `SHA-256(pin)` with AES-256-CBC. /// 5. Sends getPinToken (sub-command 0x05) and decrypts the response token. - pub fn get_pin_token(&self, pin: &str) -> Result, PFError> { + fn get_pin_token(&self, pin: &str) -> Result, PFError> { log::info!("Starting custom get_pin_token (Subcommand 0x05)..."); // 1. Get Authenticator Key Agreement @@ -1006,7 +557,7 @@ impl HidTransport { /// `getPinUvAuthTokenUsingPinWithPermissions` sub-command (0x09). This allows /// requesting only the permissions needed (e.g., `CREDENTIAL_MANAGEMENT` for /// enumeration/deletion), following the principle of least privilege. - pub fn get_pin_token_with_permission( + fn get_pin_token_with_permission( &self, pin: &str, permissions: PinUvAuthTokenPermissions, @@ -1138,7 +689,7 @@ impl HidTransport { /// /// The PIN must be 4–63 characters. Fails with `PIN_POLICY_VIOLATION` (0x37) if /// the PIN is too short. - pub fn set_pin(&self, new_pin: &str) -> Result<(), PFError> { + fn set_pin(&self, new_pin: &str) -> Result<(), PFError> { log::info!("Starting custom set_pin (Subcommand 0x03)..."); if new_pin.len() < 4 { @@ -1269,7 +820,7 @@ impl HidTransport { /// Returns `CTAP2_ERR_PIN_AUTH_INVALID` (0x31) if the current PIN is wrong, /// `CTAP2_ERR_PIN_BLOCKED` (0x32) if the PIN is blocked, or /// `CTAP2_ERR_PIN_POLICY_VIOLATION` (0x37) if the new PIN violates policy. - pub fn change_pin(&self, current_pin: &str, new_pin: &str) -> Result<(), PFError> { + fn change_pin(&self, current_pin: &str, new_pin: &str) -> Result<(), PFError> { log::info!("Starting custom change_pin (Subcommand 0x04)..."); if new_pin.len() < 4 { @@ -1503,7 +1054,7 @@ impl HidTransport { /// 3. Iterates with `EnumerateRpsGetNextRp` (sub-command 0x03) until all RPs are returned. /// /// Returns an empty vector if no credentials exist on the device. - pub fn credential_management_enumerate_rps( + fn credential_management_enumerate_rps( &self, pin: &str, ) -> Result, PFError> { @@ -1650,7 +1201,7 @@ impl HidTransport { /// 3. Iterates with `EnumerateCredentialsGetNextCredential` (sub-command 0x05). /// /// Returns user info, credential ID, and public key for each credential. - pub fn credential_management_enumerate_credentials( + fn credential_management_enumerate_credentials( &self, pin: &str, rp_id_hash: &[u8], @@ -1827,7 +1378,7 @@ impl HidTransport { /// the `DeleteCredential` command (sub-command 0x06) with the credential ID /// descriptor map. The `credential_id_map` must be a CBOR map with key 0x02 /// containing the credential ID. - pub fn credential_management_delete_credential( + fn credential_management_delete_credential( &self, pin: &str, credential_id_map: Value, @@ -1881,6 +1432,80 @@ impl HidTransport { Ok(()) } + /// Read physical configuration from an RS-Key via CTAPHID 0x41 CONFIG_READ. + /// + /// Sends `{1: 0x0D, 2: {1: target}}` CBOR payload to the RS-Key vendor + /// command handler inside a CTAPHID_CBOR message with the vendor sub-command + /// prefix. Returns raw TLV bytes for the requested target. + /// Ungated — no PIN needed. + /// + /// Targets: `RSKEY_CFG_TARGET_DEV_CONF` (0x00), `RSKEY_CFG_TARGET_PHY` (0x01), + /// `RSKEY_CFG_TARGET_LED` (0x02). + fn rs_key_config_read(&self, target: u8) -> Result, PFError> { + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Integer(RSKEY_CONFIG_READ as i128)); + + let mut target_map = BTreeMap::new(); + target_map.insert(Value::Integer(1), Value::Integer(target as i128)); + params.insert(Value::Integer(2), Value::Map(target_map)); + + let inner = to_vec(&Value::Map(params)).map_err(|e| PFError::Io(e.to_string()))?; + + let mut full_payload = vec![RSKEY_CTAPHID_VENDOR_CMD]; + full_payload.extend(inner); + self.send_cbor(CTAPHID_CBOR, &full_payload) + } + + /// Write physical configuration to an RS-Key via CTAPHID 0x41 CONFIG_WRITE. + /// + /// Sends `{1: 0x0C, 2: {1: target, 2: blob}, 3: protocol, 4: mac}` CBOR + /// to the RS-Key vendor command handler. Requires a PIN token obtained with + /// `AUTHENTICATOR_CONFIG` permission. + /// + /// The MAC is computed as `HMAC-SHA256(pin_token, 0xFF*32 || 0x41 || 0x0C || cbor_params)[..16]` + /// per the RS-Key protocol spec. + fn rs_key_config_write( + &self, + pin_token: &[u8], + target: u8, + blob: &[u8], + ) -> Result<(), PFError> { + let mut params_map = BTreeMap::new(); + params_map.insert(Value::Integer(1), Value::Integer(target as i128)); + params_map.insert(Value::Integer(2), Value::Bytes(blob.to_vec())); + let params = Value::Map(params_map); + let params_bytes = to_vec(¶ms).map_err(|e| PFError::Io(e.to_string()))?; + + // MAC = HMAC-SHA256(pin_token, 0xFF*32 || vendor_cmd || sub_cmd || cbor_params)[..16] + let mac = { + let mut input = vec![0xFFu8; 32]; + input.push(RSKEY_CTAPHID_VENDOR_CMD); + input.push(RSKEY_CONFIG_WRITE); + input.extend(¶ms_bytes); + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, pin_token); + hmac::sign(&hmac_key, &input).as_ref()[..16].to_vec() + }; + + let mut outer = BTreeMap::new(); + outer.insert( + Value::Integer(1), + Value::Integer(RSKEY_CONFIG_WRITE as i128), + ); + outer.insert(Value::Integer(2), params); + outer.insert(Value::Integer(3), Value::Integer(1)); // PIN protocol v1 + outer.insert(Value::Integer(4), Value::Bytes(mac)); + + let inner = to_vec(&Value::Map(outer)).map_err(|e| PFError::Io(e.to_string()))?; + + let mut full_payload = vec![RSKEY_CTAPHID_VENDOR_CMD]; + full_payload.extend(inner); + // CONFIG_WRITE can involve flash erasure/write which takes + // several seconds on RP2040 — use a generous timeout. + const CONFIG_WRITE_TIMEOUT_MS: i32 = 30_000; + self.send_cbor_with_timeout(CTAPHID_CBOR, &full_payload, CONFIG_WRITE_TIMEOUT_MS) + .map(|_| ()) + } + /// Sign a credential management command using HMAC-SHA-256. /// /// Uses pico-fido's non-standard signing scheme: for sub-commands 0x01 diff --git a/src/hal/firmwares/mod.rs b/src/hal/firmwares/mod.rs new file mode 100644 index 0000000..0002700 --- /dev/null +++ b/src/hal/firmwares/mod.rs @@ -0,0 +1,175 @@ +#![allow(dead_code)] + +//! Firmware-type abstraction layer. +//! +//! This module provides the [`FirmwareTrait`] trait and two concrete +//! implementations ([`PicoFidoFirmware`], [`RSKeyFirmware`]) that +//! encapsulate per-firmware capability gating. The trait methods are +//! checked throughout [`crate::hal::io`] to select the correct command +//! path for each operation. +//! +//! ## Detection +//! +//! Firmware type is determined at transport-scan time via AAGUID lookup +//! in [`AnyFirmware::detect_by_aaguid`]. The AAGUID constants live in +//! [`crate::hal::types`]. LK-ONE shares pico-fido's AAGUID and is +//! treated as a pico-fido variant. +//! +//! ## Trait methods +//! +//! | Method | What it gates | +//! |---|---| +//! | `supports_legacy_fido_hardware_config` | Whether the device accepts legacy `vendorPrototype` 0xFF commands for hardware config (pico-fido ≤ 7.2, or RS-Key which uses a separate `0x41` path). | +//! | `supports_fido_config_write` | Whether `authenticatorConfig` + `vendorPrototype` writes can be used for config. | +//! | `supports_rs_key_vendor_command` | Whether RS-Key-specific vendor commands (0x05 etc.) are available. | +//! | `supports_rescue_channel` | Whether the PC/SC rescue channel is accessible. | + +pub mod picofido; +pub mod rskey; + +pub use picofido::*; +pub use rskey::*; + +use crate::hal::common::FirmwareVersion; +use crate::hal::types::*; + +/// Dispatch enum wrapping a concrete firmware implementation. +/// +/// Most callers interact through the [`FirmwareTrait`] methods rather +/// than matching on variants directly. +#[derive(Debug, Clone)] +pub enum AnyFirmware { + PicoFido(PicoFidoFirmware), + RSKey(RSKeyFirmware), +} + +/// Capability gating per firmware variant. +/// +/// Each method queries a static or version-derived capability flag. +/// The concrete implementations in [`PicoFidoFirmware`] and +/// [`RSKeyFirmware`] encode the known compatibility boundaries for +/// each firmware. +pub trait FirmwareTrait { + fn firmware_type(&self) -> FirmwareType; + fn version(&self) -> &FirmwareVersion; + fn major_minor(&self) -> (u16, u16) { + (self.version().major, self.version().minor) + } + fn version_str(&self) -> &str { + &self.version().raw + } + + /// Whether the firmware accepts legacy FIDO hardware-config commands. + fn supports_legacy_fido_hardware_config(&self) -> bool; + /// Whether the firmware accepts FIDO config writes. + fn supports_fido_config_write(&self) -> bool; + /// Whether RS-Key-specific vendor commands are available. + fn supports_rs_key_vendor_command(&self) -> bool; + /// Whether the PC/SC rescue channel can be activated. + fn supports_rescue_channel(&self) -> bool; +} + +impl AnyFirmware { + /// Detect firmware type from the authenticator's AAGUID hex string. + pub fn detect_by_aaguid(aaguid: &str) -> FirmwareType { + if aaguid == crate::hal::types::RSKEY_AAGUID { + FirmwareType::RSKey + } else if aaguid == crate::hal::types::PICOFIDO_AAGUID + || aaguid == crate::hal::types::LKONE_AAGUID + { + FirmwareType::PicoFido + } else { + FirmwareType::Unknown + } + } + + /// Construct an `AnyFirmware` from a known firmware type and version string. + /// + /// LkOne and Unknown are treated as pico-fido variants. + pub fn new(fw_type: FirmwareType, version: &str) -> Self { + let ver = FirmwareVersion::parse(version).unwrap_or_default(); + match fw_type { + FirmwareType::PicoFido => Self::PicoFido(PicoFidoFirmware::new(ver)), + FirmwareType::RSKey => Self::RSKey(RSKeyFirmware::new(ver)), + FirmwareType::LkOne | FirmwareType::Unknown => { + Self::PicoFido(PicoFidoFirmware::new(ver)) + } + } + } + + /// Construct an `AnyFirmware` with an explicit legacy-vendor flag for pico-fido. + /// + /// The flag is only meaningful for `FirmwareType::PicoFido`; other types + /// ignore it. + pub fn new_with_legacy(fw_type: FirmwareType, version: &str, has_legacy_vendor: bool) -> Self { + let ver = FirmwareVersion::parse(version).unwrap_or_default(); + match fw_type { + FirmwareType::PicoFido => { + Self::PicoFido(PicoFidoFirmware::new(ver).with_legacy_vendor(has_legacy_vendor)) + } + FirmwareType::RSKey => Self::RSKey(RSKeyFirmware::new(ver)), + FirmwareType::LkOne | FirmwareType::Unknown => { + Self::PicoFido(PicoFidoFirmware::new(ver)) + } + } + } + + /// Delegate to the inner firmware's version. + pub fn version(&self) -> &FirmwareVersion { + match self { + Self::PicoFido(fw) => fw.version(), + Self::RSKey(fw) => fw.version(), + } + } + + /// Return the concrete [`FirmwareType`] of the inner firmware. + pub fn firmware_type(&self) -> FirmwareType { + match self { + Self::PicoFido(_) => FirmwareType::PicoFido, + Self::RSKey(_) => FirmwareType::RSKey, + } + } + + /// Whether the inner firmware supports legacy FIDO hardware-config commands. + pub fn supports_legacy_fido_hardware_config(&self) -> bool { + match self { + Self::PicoFido(fw) => fw.supports_legacy_fido_hardware_config(), + Self::RSKey(fw) => fw.supports_legacy_fido_hardware_config(), + } + } + + /// Whether the inner firmware supports FIDO config writes. + pub fn supports_fido_config_write(&self) -> bool { + match self { + Self::PicoFido(fw) => fw.supports_fido_config_write(), + Self::RSKey(fw) => fw.supports_fido_config_write(), + } + } + + /// Whether the inner firmware supports the new-style (post-v7.2) FIDO hardware config path. + /// + /// This is the logical negation of `supports_legacy_fido_hardware_config` for + /// pico-fido, and always `false` for RS-Key. + pub fn supports_new_fido_hardware_config(&self) -> bool { + match self { + Self::PicoFido(fw) => !fw.supports_legacy_fido_hardware_config(), + Self::RSKey(_) => false, + } + } + + /// Whether RS-Key-specific vendor commands are available on the inner firmware. + pub fn supports_rs_key_vendor_command(&self) -> bool { + match self { + Self::PicoFido(_) => false, + Self::RSKey(fw) => fw.supports_rs_key_vendor_command(), + } + } + + /// Whether the PC/SC rescue channel can be used with the inner firmware. + pub fn supports_rescue_channel(&self) -> bool { + match self { + Self::PicoFido(_) => true, + Self::RSKey(_) => true, + } + } +} diff --git a/src/hal/firmwares/picofido.rs b/src/hal/firmwares/picofido.rs new file mode 100644 index 0000000..fe96bf1 --- /dev/null +++ b/src/hal/firmwares/picofido.rs @@ -0,0 +1,67 @@ +//! pico-fido / pico-keys-sdk firmware implementation. +//! +//! The [`PicoFidoFirmware`] struct stores a parsed version and an optional +//! legacy-vendor flag. The `supports_legacy_fido_hardware_config` method +//! returns `true` when the firmware is ≤ 7.2 or when the legacy probe +//! succeeded – gating the old `vendorPrototype` 0xFF command path. + +use crate::hal::common::FirmwareVersion; +use crate::hal::firmwares::FirmwareTrait; +use crate::hal::types::FirmwareType; + +/// Firmware implementation for pico-fido / pico-keys-sdk devices. +/// +/// Version-gates the legacy vendor-prototype hardware config path: +/// - Versions ≤ 7.2 (major < 7, or 7.x where x ≤ 2) support the legacy path. +/// - Versions ≥ 7.3 require the rescue channel or the new-style config. +/// - An explicit `has_legacy_vendor` probe result can override the version check. +#[derive(Debug, Clone)] +pub struct PicoFidoFirmware { + version: FirmwareVersion, + /// Whether the device responded positively to the legacy + /// VendorPrototype 0xFF probe (PicoForge CONFIG_PHY_* commands). + has_legacy_vendor: bool, +} + +impl PicoFidoFirmware { + /// Create a new pico-fido firmware state with no legacy vendor flag. + pub fn new(version: FirmwareVersion) -> Self { + Self { + version, + has_legacy_vendor: false, + } + } + + pub fn with_legacy_vendor(mut self, legacy: bool) -> Self { + self.has_legacy_vendor = legacy; + self + } +} + +impl FirmwareTrait for PicoFidoFirmware { + fn firmware_type(&self) -> FirmwareType { + FirmwareType::PicoFido + } + + fn version(&self) -> &FirmwareVersion { + &self.version + } + + fn supports_legacy_fido_hardware_config(&self) -> bool { + self.has_legacy_vendor + || self.version.major < 7 + || (self.version.major == 7 && self.version.minor <= 2) + } + + fn supports_fido_config_write(&self) -> bool { + self.has_legacy_vendor || self.version.major >= 7 + } + + fn supports_rs_key_vendor_command(&self) -> bool { + false + } + + fn supports_rescue_channel(&self) -> bool { + true + } +} diff --git a/src/hal/firmwares/rskey.rs b/src/hal/firmwares/rskey.rs new file mode 100644 index 0000000..91a1f70 --- /dev/null +++ b/src/hal/firmwares/rskey.rs @@ -0,0 +1,58 @@ +//! RS-Key firmware implementation. +//! +//! RS-Key reports its SDK version (e.g. 5.7) via CTAP `GetInfo`, which +//! does not directly map to the RS-Key release version. Because of this, +//! capability gating uses static values and runtime probes rather than +//! version checks. RS-Key supports both `legacy_fido_hardware_config` +//! (via the `0x41` CONFIG_READ/CONFIG_WRITE path) and the rescue channel. + +use crate::hal::common::FirmwareVersion; +use crate::hal::firmwares::FirmwareTrait; +use crate::hal::types::FirmwareType; + +#[derive(Debug, Clone)] +pub struct RSKeyFirmware { + version: FirmwareVersion, +} + +impl RSKeyFirmware { + pub fn new(version: FirmwareVersion) -> Self { + Self { version } + } +} + +impl FirmwareTrait for RSKeyFirmware { + fn firmware_type(&self) -> FirmwareType { + FirmwareType::RSKey + } + + fn version(&self) -> &FirmwareVersion { + &self.version + } + + /// RS-Key reports firmware 5.x (< 7) per the SDK version scheme. + /// Per the protocol integration notes, this version range triggers + /// PicoForge's legacy hardware-config path (authenticatorConfig + + /// vendorPrototype) which RS-Key supports for writes, and for reads + /// it tries the 0x41 CONFIG_READ path instead. + fn supports_legacy_fido_hardware_config(&self) -> bool { + false + } + + /// RS-Key supports FIDO config write via CTAPHID 0x41 CONFIG_WRITE + /// on v0.3.1+. The CTAP firmware version from GET_INFO reports the SDK + /// version (e.g., 5.7) which does not map to the RS-Key release version, + /// so we cannot version-gate here. Actual support is determined via a + /// runtime CONFIG_READ probe in write_rskey_config(). + fn supports_fido_config_write(&self) -> bool { + true + } + + fn supports_rs_key_vendor_command(&self) -> bool { + true + } + + fn supports_rescue_channel(&self) -> bool { + true + } +} diff --git a/src/hal/io.rs b/src/hal/io.rs index 6e26dbb..6e91031 100644 --- a/src/hal/io.rs +++ b/src/hal/io.rs @@ -1,31 +1,144 @@ -//! Device I/O layer bridging rescue (pcsc) and FIDO2 protocols. +//! High-level device I/O dispatching across FIDO and Rescue protocols. //! -//! High-level entry points for reading/writing device configuration, -//! managing credentials, and controlling LED/boot behavior. -//! -//! Functions are grouped by the protocol they use: -//! - Functions that use both rescue and FIDO (fallback/dispatch logic) -//! - Functions that communicate exclusively over the rescue (PC/SC) channel -//! - Functions that communicate exclusively over the FIDO2 channel +//! Each public function here selects the appropriate protocol path based +//! on the detected firmware type or an explicit [`DeviceMethod`] parameter. +//! Some functions (e.g. `read_device_details`) try FIDO first and merge +//! results from Rescue to produce a complete status snapshot. -#![allow(unused)] +use crate::{ + error::PFError, + hal::{fido, rescue, transport::DeviceHandle, types::*}, +}; -use crate::{error::PFError, hal::fido, hal::rescue, hal::types::*}; - -// ── Shared: functions that use both rescue and FIDO ───────────────────────── - -/// Read full device status. Tries rescue first, falls back to FIDO on failure. +/// Read full device status by merging FIDO and Rescue data where available. +/// +/// Tries the FIDO HID transport first, then falls back to the PC/SC +/// rescue channel. When both succeed, fields from the more detailed +/// source are used (e.g. serial/flash from Rescue, AAGUID from FIDO). pub fn read_device_details() -> Result { - match rescue::read_device_details() { - Ok(status) => Ok(status), - Err(e) => { - log::warn!("Rescue method failed: {}. Falling back to FIDO...", e); - fido::read_device_details() + let mut fido_status: Option = None; + let mut rescue_status: Option = None; + let mut rescue_fw_type: Option = None; + + // Discover via FIDO/HID transport + match DeviceHandle::try_fido() { + Ok(Some((_handle, _identity))) => match fido::read_device_details() { + Ok(status) => { + log::info!("FIDO device details read successfully"); + fido_status = Some(status); + } + Err(e) => log::warn!("FIDO read_device_details failed: {}", e), + }, + Ok(None) => log::info!("No FIDO HID device found"), + Err(e) => log::warn!("FIDO HID discovery error: {}", e), + } + + // Discover via Rescue/PC/SC transport + match DeviceHandle::try_rescue() { + Ok(Some((handle, _identity))) => { + rescue_fw_type = Some(handle.firmware_type()); + match rescue::read_device_details() { + Ok(status) => { + log::info!("Rescue device details read successfully"); + rescue_status = Some(status); + } + Err(e) => log::warn!("Rescue read_device_details failed: {}", e), + } + } + Ok(None) => log::info!("No Rescue PC/SC device found"), + Err(e) => log::warn!("Rescue PC/SC discovery error: {}", e), + } + + match (fido_status, rescue_status) { + (Some(fido), Some(rescue)) => { + log::info!("Merging FIDO and Rescue device details"); + Ok(FullDeviceStatus { + info: DeviceInfo { + serial: rescue.info.serial, + flash_used: rescue.info.flash_used, + flash_total: rescue.info.flash_total, + firmware_version: fido.info.firmware_version, + }, + config: AppConfig { + vid: if !rescue.config.vid.is_empty() { + rescue.config.vid + } else { + fido.config.vid + }, + pid: if !rescue.config.pid.is_empty() { + rescue.config.pid + } else { + fido.config.pid + }, + led_gpio: rescue.config.led_gpio, + led_brightness: rescue.config.led_brightness, + led_dimmable: rescue.config.led_dimmable, + power_cycle_on_reset: rescue.config.power_cycle_on_reset, + led_steady: rescue.config.led_steady, + enable_secp256k1: rescue.config.enable_secp256k1, + led_driver: rescue.config.led_driver.or_else(|| { + if fido.config.led_driver.is_some() { + fido.config.led_driver + } else { + None + } + }), + product_name: rescue.config.product_name, + touch_timeout: rescue.config.touch_timeout, + raw_curves_mask: rescue.config.raw_curves_mask, + led_order: rescue.config.led_order, + enabled_usb_itf: rescue.config.enabled_usb_itf, + led_num: rescue.config.led_num, + }, + secure_boot: rescue.secure_boot, + secure_lock: rescue.secure_lock, + method: DeviceMethod::Fido, + firmware_type: fido.firmware_type, + }) + } + (Some(fido), None) => { + log::info!("Using FIDO-only device details"); + Ok(FullDeviceStatus { + firmware_type: fido.firmware_type, + ..fido + }) + } + (None, Some(rescue)) => { + log::info!("Using Rescue-only device details"); + let ft = rescue_fw_type.and_then(|ft| { + if rescue.firmware_type == FirmwareType::Unknown { + Some(ft) + } else { + None + } + }); + Ok(FullDeviceStatus { + firmware_type: ft.unwrap_or(rescue.firmware_type), + ..rescue + }) + } + (None, None) => { + log::error!("Failed to read device details via both FIDO and Rescue"); + Err(PFError::NoDevice) } } } -/// Write app config. Dispatches to rescue or FIDO based on `method`. +#[allow(dead_code)] +/// Enable or lock secure boot on the device (Rescue-only operation). +pub fn enable_secure_boot(lock: bool) -> Result { + rescue::enable_secure_boot(lock) +} + +#[allow(dead_code)] +/// Reboot the device (normal or BOOTSEL mode) via the Rescue channel. +pub fn reboot(to_bootsel: bool) -> Result { + rescue::reboot_device(to_bootsel) +} + +/// Write device configuration, selecting FIDO or Rescue path by method. +/// +/// The FIDO path requires a PIN; the Rescue path does not. pub fn write_config( config: AppConfigInput, method: DeviceMethod, @@ -38,51 +151,80 @@ pub fn write_config( } } -// ── Rescue protocol (PC/SC) ───────────────────────────────────────────────── - -/// Lock or unlock secure boot via rescue. -pub fn enable_secure_boot(lock: bool) -> Result { - rescue::enable_secure_boot(lock) +/// Read the LED status configuration via the specified transport method. +pub fn read_led_config(method: DeviceMethod) -> Result { + match method { + DeviceMethod::Fido => { + let transport = crate::hal::transport::fido::HidTransport::open()?; + fido::read_rskey_led_config(&transport) + } + DeviceMethod::Rescue => rescue::read_led_config(), + } } -/// Reboot the device. Pass `true` to enter BOOTSEL mode. -pub fn reboot(to_bootsel: bool) -> Result { - rescue::reboot_device(to_bootsel) -} - -/// Read current LED status config via rescue. -pub fn read_led_config() -> Result { - rescue::read_led_config() -} - -/// Write LED status (on/off, color, brightness, steady/blinking). -pub fn write_led_status( - status: u8, - color: u8, - brightness: u8, - steady: bool, +/// Write LED status configuration (all four status slots) via the specified transport. +pub fn write_led_config( + method: DeviceMethod, + config: LedStatusConfig, + pin: Option, ) -> Result { - rescue::write_led_status(status, color, brightness, steady) + match method { + DeviceMethod::Fido => { + let pin = pin.ok_or_else(|| { + PFError::Device("PIN is required for FIDO LED config write".into()) + })?; + let transport = crate::hal::transport::fido::HidTransport::open()?; + fido::write_rskey_led_config(&transport, &config, &pin) + } + DeviceMethod::Rescue => { + for i in 0..4 { + let (color, brightness) = config.statuses[i]; + rescue::write_led_status(i as u8, color, brightness, config.steady)?; + } + Ok("LED configuration applied successfully.".to_string()) + } + } } -/// Read management app config via rescue. -pub fn read_management_config() -> Result { - rescue::read_management_config() +/// Read USB interface configuration from the Management applet. +pub fn read_management_config(method: DeviceMethod) -> Result { + match method { + DeviceMethod::Fido => { + let transport = crate::hal::transport::fido::HidTransport::open()?; + let info = fido::read_rskey_management_info(&transport)?; + Ok(ManagementAppConfig { + usb_supported: info.usb_supported.unwrap_or(0), + usb_enabled: info.usb_enabled.unwrap_or(0), + }) + } + DeviceMethod::Rescue => rescue::read_management_config(), + } } -/// Write management app enabled-mask via rescue. -pub fn write_management_config(enabled_mask: u16) -> Result { - rescue::write_management_config(enabled_mask) +/// Write the USB interface enable mask via the specified transport. +pub fn write_management_config( + method: DeviceMethod, + enabled_mask: u16, + pin: Option, +) -> Result { + match method { + DeviceMethod::Fido => { + let pin = pin.ok_or_else(|| { + PFError::Device("PIN is required for FIDO management config write".into()) + })?; + let transport = crate::hal::transport::fido::HidTransport::open()?; + fido::write_rskey_dev_config(&transport, enabled_mask, &pin) + } + DeviceMethod::Rescue => rescue::write_management_config(enabled_mask), + } } -// ── FIDO2 protocol ────────────────────────────────────────────────────────── - -/// Query basic FIDO device info (AAGUID, version, etc.). +/// Retrieve the FIDO authenticator metadata (GetInfo) as [`FidoDeviceInfo`]. pub(crate) fn get_fido_info() -> Result { fido::get_fido_info() } -/// Change the FIDO user PIN. +/// Change the FIDO PIN from `current_pin` to `new_pin`. pub(crate) fn change_fido_pin( current_pin: Option, new_pin: String, @@ -90,7 +232,7 @@ pub(crate) fn change_fido_pin( fido::change_fido_pin(current_pin, new_pin) } -/// Set the minimum PIN length requirement. +/// Set a new minimum PIN length on the authenticator. pub(crate) fn set_min_pin_length( current_pin: String, min_pin_length: u8, @@ -98,32 +240,32 @@ pub(crate) fn set_min_pin_length( fido::set_min_pin_length(current_pin, min_pin_length) } -/// List stored credentials for the given PIN. +/// Enumerate all credentials stored on the authenticator. pub fn get_credentials(pin: String) -> Result, String> { fido::get_credentials(pin) } -/// Delete a single credential by its ID. +/// Delete a credential from the authenticator by credential ID. pub fn delete_credential(pin: String, credential_id: String) -> Result { fido::delete_credential(pin, credential_id) } -/// Factory-reset the device, wiping all credentials and settings. +/// Perform a factory reset on the authenticator. pub fn reset_device() -> Result { fido::reset_device() } -/// Enable enterprise attestation for the device. +/// Enable enterprise attestation on the authenticator. pub fn enable_enterprise_attestation(pin: String) -> Result { fido::enable_enterprise_attestation(pin) } -/// Retrieve the enterprise attestation CSR. +/// Retrieve the enterprise attestation CSR from the authenticator. pub fn get_enterprise_attestation_csr() -> Result { fido::get_enterprise_attestation_csr() } -/// Upload a signed enterprise attestation certificate. +/// Upload an X.509 certificate for enterprise attestation. pub fn upload_enterprise_attestation_cert( pin: String, cert_path: String, diff --git a/src/hal/mod.rs b/src/hal/mod.rs index 96ec7a0..96dc73a 100644 --- a/src/hal/mod.rs +++ b/src/hal/mod.rs @@ -1,45 +1,42 @@ -//! Device communication layer for pico-forge. +//! Hardware abstraction layer — all device communication lives here. //! //! ```text -//! device/ -//! ├── mod.rs — module root, re-exports submodules -//! ├── io.rs — high-level entry points (both protocols) -//! ├── rescue.rs — rescue / PC/SC protocol implementation -//! ├── fido.rs — FIDO2 / CTAP2 protocol implementation -//! └── types.rs — shared structs, enums, and constants +//! hal/ +//! ├── mod.rs — module root +//! ├── io.rs — high-level entry points dispatching across protocols +//! ├── types.rs — shared structs, enums, and constants +//! ├── common/ — COSE algorithm/curve enums and firmware-version parsing +//! │ ├── cose.rs +//! │ └── version.rs +//! ├── firmwares/ — per-firmware capability gating (PicoFido, RSKey) +//! │ ├── picofido.rs +//! │ └── rskey.rs +//! ├── transport/ — physical transport abstractions (HID, PC/SC) +//! │ ├── fido.rs — CTAPHID framing over USB HID +//! │ └── pcsc.rs — ISO 7816-4 APDU over PC/SC +//! ├── fido/ — FIDO2 / CTAP2 protocol implementation +//! │ ├── constants.rs — CTAP2 command codes, CBOR keys, vendor commands +//! │ └── ops.rs — FidoOperations trait, PIN/credential management +//! └── rescue/ — Rescue applet protocol (PC/SC APDU) +//! ├── constants.rs — ISO 7816-4 constants, PHY tags, vendor AIDs +//! └── ops.rs — RescueOperations trait //! ``` //! -//! # Overview +//! # Architecture //! -//! The `device` module is the only place that talks to the hardware token. -//! Everything above it (UI state, gpui-component views) depends on the -//! public functions exported here; nothing below it should know about the -//! communication details. -//! -//! Two protocols are used: -//! -//! - **Rescue (PC/SC)** — low-level APDU channel for firmware-level -//! configuration: secure boot, LED status, USB applet management, and -//! device reboot. Implemented in [`rescue`]. -//! -//! - **FIDO2 (CTAP2)** — standard authenticator protocol for credential -//! management, PIN operations, and enterprise attestation. -//! Implemented in [`fido`]. -//! -//! [`io`] sits on top of both and exposes a single function per device -//! operation. Some functions dispatch to one protocol or the other based -//! on a [`types::DeviceMethod`] flag; others try rescue first and fall back -//! to FIDO on failure. -//! -//! # Adding a new device operation -//! -//! 1. Add any new structs/enums to [`types`]. -//! 2. Implement the raw protocol call in [`rescue`] or [`fido`]. -//! 3. Expose a high-level wrapper in [`io`] that picks the right protocol -//! and converts errors to the caller's expected type. -//! 4. Wire the wrapper into a gpui-component view or action handler. +//! [`types`] defines the data types shared across all submodules. +//! [`firmwares`] provides [`AnyFirmware`](crate::hal::firmwares::AnyFirmware) with per-firmware capability +//! checks (e.g. legacy vs new vendor commands). +//! [`transport`] discovers the device and returns a [`DeviceHandle`](crate::hal::transport::DeviceHandle) +//! wrapping either a FIDO HID or Rescue PC/SC connection. +//! [`fido`] and [`rescue`] implement the protocol-level operations. +//! [`io`] sits on top and exposes one function per device operation, +//! selecting the correct protocol path based on the detected firmware. +pub mod common; pub mod fido; +pub mod firmwares; pub mod io; pub mod rescue; +pub mod transport; pub mod types; diff --git a/src/hal/rescue/constants.rs b/src/hal/rescue/constants.rs index c5aad9d..93bcd67 100644 --- a/src/hal/rescue/constants.rs +++ b/src/hal/rescue/constants.rs @@ -124,10 +124,13 @@ pub enum RescueInstruction { /// Data field contains the tag value to write. Write = 0x1C, - /// Lock or unlock device access. + /// Lock or unlock device access (pico-fido only, not RS-Key). /// /// P2 parameter determines lock state (0x00=Unlock, 0x01=Lock). /// When locked, PHY configuration commands are rejected. + /// + /// **Note**: This instruction is only available on pico-fido firmware + /// (RP2350/ESP32). RS-Key uses `OtpLock = 0x1B` instead. Secure = 0x1D, /// Read hardware configuration from flash memory. @@ -241,7 +244,8 @@ pub const P2_UNUSED: u8 = 0x00; /// commands to access hardware configuration. /// /// PHY configuration is shared between pico-fido and RS-Key, with RS-Key adding -/// additional tags like `LedOrder` for RGB LED support. +/// additional tags like `LedOrder` for RGB LED support and `LedNum` for +/// multi-LED count. /// /// References: /// - [pico-fido](https://github.com/polhenarejos/pico-fido) `src/fs/phy.h` @@ -308,6 +312,13 @@ pub enum PhyTag { /// Data format: `[ORDER]` (1 byte). /// RS-Key specific tag for configuring LED color channel order. LedOrder = 0x0D, + + /// Number of LEDs on the device (RS-Key extension). + /// + /// Data format: `[COUNT]` (1 byte). + /// RS-Key specific tag specifying how many individual LEDs + /// are present (e.g., 1 for single, 3 for RGB). + LedNum = 0x0E, } impl PhyTag { @@ -327,6 +338,7 @@ impl PhyTag { 0x0B => Some(Self::EnabledUsbItf), 0x0C => Some(Self::LedDriver), 0x0D => Some(Self::LedOrder), + 0x0E => Some(Self::LedNum), _ => None, } } @@ -344,6 +356,13 @@ impl PhyTag { /// - [RS-Key](https://github.com/TheMaxMur/RS-Key) `crates/rsk-rescue/src/phy.rs` bitflags::bitflags! { pub struct RescueOptions: u16 { + /// Windows Compatible ID (WCID) support. + /// + /// When set, the device advertises WCID descriptors for + /// automatic driver installation on Windows without + /// requiring a custom .inf file. + const WCID = 0x01; + /// LED supports dimming (PWM control). /// /// When set, the LED brightness can be adjusted. When clear, diff --git a/src/hal/rescue/mod.rs b/src/hal/rescue/mod.rs index 05f6385..13690a1 100644 --- a/src/hal/rescue/mod.rs +++ b/src/hal/rescue/mod.rs @@ -1,954 +1,60 @@ -//! Rescue applet implementation for pico-fido and RS-Key firmware. +//! Application-level routines for interacting with the `Rescue Applet`. //! -//! ```text -//! rescue/ -//! ├── mod.rs — high-level rescue operations (read/write config, reboot, LED, management) -//! └── constants.rs — ISO 7816-4 constants, rescue instructions, PHY tags, vendor applets -//! ``` +//! The Rescue Applet (`A0 58 3F C1 9B 7E 4F 21`) is used for out-of-band management of the Pico FIDO key, +//! allowing configuration of device parameters before FIDO provisioning occurs. //! -//! # What is the Rescue Applet? -//! -//! The Rescue applet is a low-level firmware recovery and hardware configuration -//! interface that operates independently of the FIDO2/CTAP2 stack. It provides -//! direct access to device hardware settings, flash memory, and security features -//! through a proprietary APDU-based protocol. -//! -//! Both [pico-fido](https://github.com/polhenarejos/pico-fido) (C) and -//! [RS-Key](https://github.com/TheMaxMur/RS-Key) (Rust) firmware implement -//! this applet with the same AID and command set. -//! -//! # Why is Rescue Mode Needed? -//! -//! FIDO2 devices expose a standardized interface (CTAP2) that abstracts away -//! hardware details. However, there are scenarios where direct hardware access -//! is required: -//! -//! - **Firmware recovery**: When FIDO mode is unresponsive or corrupted -//! - **Hardware configuration**: Changing USB VID/PID, LED settings, touch timeout -//! without requiring FIDO PIN authentication -//! - **Secure boot management**: Enabling/disabling secure boot, reading OTP status -//! - **Device provisioning**: Uploading attestation certificates, setting serial numbers -//! - **Firmware updates**: Rebooting into bootloader (BOOTSEL) mode for flashing -//! -//! The Rescue applet runs on the CCID (smart card) USB interface, which is always -//! available even when FIDO functionality is disabled or misconfigured. -//! -//! # Communication Protocol: PC/SC -//! -//! Unlike FIDO2 which uses USB HID (CTAPHID), the Rescue applet communicates via -//! **PC/SC** (Personal Computer/Smart Card) — the standard protocol for interacting -//! with smart card readers and ICCs (Integrated Circuit Cards). -//! -//! ```text -//! Host Application -//! │ -//! ▼ -//! pcsc-lite daemon (pcscd) ← Linux/macOS daemon -//! │ -//! ▼ -//! USB CCID Class Driver ← Smart card reader driver -//! │ -//! ▼ -//! Device CCID Interface ← Composite USB device -//! │ -//! ▼ -//! Rescue Applet (APDU commands) ← Firmware -//! ``` -//! -//! ## PC/SC Architecture -//! -//! The PC/SC specification defines a standard API for communicating with smart -//! cards. In our case, the RP2040/RP2350 device emulates a CCID-compliant smart -//! card reader with an embedded ICC. -//! -//! Key concepts: -//! - **Context**: A connection to the PC/SC daemon (establishes resource manager) -//! - **Reader**: A physical or virtual smart card reader (our device appears as one) -//! - **Card**: A connection to a specific card in a reader -//! - **APDU**: Application Protocol Data Unit — the command/response format -//! -//! ## APDU Command Structure -//! -//! ```text -//! ┌─────┬─────┬─────┬─────┬─────┬─────────────┐ -//! │ CLA │ INS │ P1 │ P2 │ Lc │ Data │ -//! └─────┴─────┴─────┴─────┴─────┴─────────────┘ -//! 1B 1B 1B 1B 0-1B 0-255 bytes -//! ``` -//! -//! - **CLA** (0x80 for Rescue): Command class — proprietary extension -//! - **INS**: Instruction code (e.g., 0x1E for READ, 0x1C for WRITE) -//! - **P1/P2**: Parameters (sub-command selectors) -//! - **Lc**: Length of data field -//! - **Data**: Command payload -//! -//! Response ends with Status Words (SW1 SW2): -//! - `0x90 0x00`: Success -//! - `0x6A 0x82`: File/application not found -//! - `0x69 0x82`: Security status not satisfied -//! -//! # Data Flow -//! -//! ```text -//! io::read_device_details() -//! │ -//! ▼ -//! rescue::read_device_details() ← this file -//! │ -//! ▼ -//! connect_and_select() ← PC/SC connection + applet selection -//! │ -//! ▼ -//! card.transmit(apdu) ← ISO 7816-4 APDU exchange -//! │ -//! ▼ -//! PC/SC (CCID USB interface) -//! ``` -//! -//! ## Applet Selection -//! -//! Every session begins with applet selection: -//! -//! ```text -//! APDU: 00 A4 04 04 08 A0 58 3F C1 9B 7E 4F 21 -//! ── ── ── ── ── ───────────────────────── -//! CLA INS P1 P2 Len AID (Rescue Applet) -//! ``` -//! -//! The SELECT response contains device identity: -//! - Byte 0: MCU type (1=RP2350, 2=ESP32-S3, etc.) -//! - Byte 1: Product type (2=FIDO) -//! - Byte 2: SDK version major -//! - Byte 3: SDK version minor -//! - Bytes 4-11: Serial number (8 bytes) -//! -//! # Module Structure -//! -//! [`constants`] defines all protocol constants shared between pico-fido and RS-Key: -//! - ISO 7816-4 command bytes (CLA, INS, P1, P2, SW) -//! - Rescue instruction codes and parameters -//! - PHY configuration tags and bitflags -//! - Vendor applet AIDs and instructions (LED, Management) -//! -//! This module contains the public functions called from [`super::io`]: -//! - `read_device_details()`: Reads full device status via Rescue -//! - `write_config()`: Writes PHY configuration (VID/PID, LED, curves, etc.) -//! - `reboot_device()`: Reboots device (normal or BOOTSEL mode) -//! - `enable_secure_boot()`: Enables secure boot (WIP) -//! - `read_led_config()` / `write_led_status()`: LED color configuration (RS-Key) -//! - `read_management_config()` / `write_management_config()`: USB interface config (RS-Key) -//! -//! # Firmware Differences -//! -//! | Feature | pico-fido | RS-Key | -//! |---------|-----------|--------| -//! | Language | C | Rust | -//! | Rescue AID | `A0 58 3F C1 9B 7E 4F 21` | Same | -//! | Secure Boot | `INS_SECURE` (0x1D) | `INS_OTP_LOCK` (0x1B) — irreversible | -//! | LED Applet | Not available | Available (AID: `F0 00 00 00 01`) | -//! | Management | Not available | Available (Yubico-compatible) | -//! | Anti-rollback | Not available | Available (OTP fuses) | -//! -//! # References -//! -//! - [pico-fido Rescue](https://github.com/polhenarejos/pico-fido/blob/main/src/rescue.c) -//! - [RS-Key Rescue](https://github.com/TheMaxMur/RS-Key/blob/main/crates/rsk-rescue/src/lib.rs) -//! - [PC/SC Specification](https://pcsc1groupwg.readthedocs.io/) -//! - [ISO 7816-4](https://www.iso.org/standard/74873.html) -//! - [CCID Specification](https://www.usb.org/document-library/class-specification-12-chip-smart-card-interface) +//! This module delegates all APDU logic to `RescueOperations` implemented on `PcscTransport`. pub mod constants; +pub mod ops; use crate::error::PFError; -use crate::hal::{rescue::constants::*, types::*}; -use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; -use pcsc::{Context, Protocols, Scope, ShareMode}; -use std::io::Cursor; +use crate::hal::transport::pcsc::PcscTransport; +use crate::hal::types::*; +use ops::RescueOperations; -/// Establishes a PC/SC connection to the first available smart card reader and selects the Rescue Applet. -/// -/// Sends a SELECT APDU (`00 A4 04 04 08 A0 58 3F C1 9B 7E 4F 21`) to the device via the CCID interface. -/// The response contains device identity data (MCU type, product type, firmware version, serial number). -/// -/// # Returns -/// A tuple of `(Card, SelectResponse, FirmwareType)` where: -/// - `Card` is the active PC/SC card handle for subsequent APDU exchanges -/// - `SelectResponse` is the raw FCI/identity data from the SELECT command -/// - `FirmwareType` is detected as `RSKey`, `PicoFido`, or `Unknown` -/// -/// # Errors -/// - `PFError::NoDevice` if no smart card reader is found -/// - `PFError::Pcsc` if the PC/SC context cannot be established -/// - `PFError::Device` if the Rescue Applet is not found (wrong AID or device in wrong mode) -fn connect_and_select() -> Result<(pcsc::Card, Vec, FirmwareType), PFError> { - let ctx = Context::establish(Scope::User).map_err(|e| { - log::error!("Failed to establish PCSC context: {}", e); - PFError::Pcsc(e) - })?; - - let mut readers_buf = [0; 2048]; - let mut readers = ctx.list_readers(&mut readers_buf)?; - - // Use the first reader found - let reader = readers.next().ok_or_else(|| { - log::info!("No Smart Card Reader found"); - PFError::NoDevice - })?; - - let reader_name = reader.to_string_lossy(); - let mut fw_type = if reader_name.contains("RS-Key") || reader_name.contains("RSK") { - FirmwareType::RSKey - } else { - FirmwareType::Unknown - }; - - let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?; - - // Select Applet APDU: 00 A4 04 04 [Len] [AID] - let mut apdu = vec![ - APDU_CLA_ISO, - APDU_INS_SELECT, - APDU_P1_SELECT_BY_DF_NAME, - APDU_P2_RETURN_FCI, - RESCUE_AID.len() as u8, - ]; - apdu.extend_from_slice(RESCUE_AID); - - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - // Check Success (0x90 0x00) - if !rx.ends_with(&[0x90, 0x00]) { - log::error!("Rescue Applet not found on the device!"); - return Err(PFError::Device( - "Rescue Applet not found on device. Is it in FIDO mode?".into(), - )); - } - - let data = rx.to_vec(); - - if fw_type == FirmwareType::Unknown { - if data.len() >= 4 && data[2] >= 8 { - fw_type = FirmwareType::RSKey; - } else { - fw_type = FirmwareType::PicoFido; - } - } - - log::info!("Successfully connected to Rescue Applet"); - log::info!("Detected firmware type: {:?}", fw_type); - Ok((card, data, fw_type)) -} - -/// Reads comprehensive device details including identity, flash usage, secure boot status, and PHY configuration. -/// -/// Performs three sequential APDU operations after applet selection: -/// 1. SELECT response is parsed for MCU type, firmware version, and serial number -/// 2. `READ(FlashInfo)` — reads flash usage statistics (free, used, total) -/// 3. `READ(SecureBootStatus)` — reads secure boot enable/lock state -/// 4. `READ(PhyConfig)` — reads TLV-encoded hardware configuration (VID/PID, LED, curves, etc.) -/// -/// # Returns -/// A `FullDeviceStatus` struct containing device info, parsed PHY config, and secure boot state. -/// -/// # Errors -/// - `PFError::Device` if the SELECT response is malformed or any READ command fails -/// - `PFError::NoDevice` if no reader is available +/// Read full device status via the Rescue applet (PC/SC transport). pub fn read_device_details() -> Result { - log::info!("Reading full device details"); - let (card, select_resp, fw_type) = connect_and_select()?; - - log::info!("Select Response: {:?}", select_resp); - - // FIX: Relax the length check. - // Minimum valid response is 4 bytes data + 2 bytes SW = 6 bytes. - if select_resp.len() < 6 { - log::error!("Invalid select response length: {}", select_resp.len()); - return Err(PFError::Device("Invalid select response".into())); - } - - let version_major = select_resp[2]; - let version_minor = select_resp[3]; - - // FIX: Handle missing Serial Number safely - // If the firmware sends 14 bytes, we have a serial. If it sends 6, we don't. - let serial_str = if select_resp.len() >= 14 { - hex::encode_upper(&select_resp[4..12]) - } else { - log::warn!( - "Device did not return a Serial Number (Firmware mismatch?). Using placeholder." - ); - "00000000".to_string() - }; - - log::info!("Device Version: {}.{}", version_major, version_minor); - log::info!("Device Serial: {}", serial_str); - - // 2. Read Flash Info - let mut rx_buf = [0; 256]; - let rx_flash = card.transmit( - &[ - APDU_CLA_PROPRIETARY, - RescueInstruction::Read as u8, - ReadParam::FlashInfo as u8, - P2_UNUSED, - 0x00, // Le - ], - &mut rx_buf, - )?; - - if !rx_flash.ends_with(&SW_SUCCESS) { - return Err(PFError::Device("Failed to read flash".into())); - } - - let mut rdr = Cursor::new(&rx_flash[..rx_flash.len() - 2]); - let _free = rdr.read_u32::().unwrap_or(0); - let used = rdr.read_u32::().unwrap_or(0); - let total = rdr.read_u32::().unwrap_or(0); - - // NOTE: captured but currently unused variables - let _nfiles = rdr.read_u32::().unwrap_or(0); - let _chip_size = rdr.read_u32::().unwrap_or(0); - - // --- Read Secure Boot Status --- - let rx_secure = card.transmit( - &[ - APDU_CLA_PROPRIETARY, - RescueInstruction::Read as u8, - ReadParam::SecureBootStatus as u8, - P2_UNUSED, - 0x00, - ], - &mut rx_buf, - )?; - - let (sb_enabled, sb_locked) = if rx_secure.ends_with(&[0x90, 0x00]) && rx_secure.len() >= 4 { - (rx_secure[0] != 0, rx_secure[1] != 0) - } else { - (false, false) - }; // --- Read PHY Config --- - let rx_phy = card.transmit( - &[ - APDU_CLA_PROPRIETARY, - RescueInstruction::Read as u8, - ReadParam::PhyConfig as u8, - 0x01, - 0x00, - ], - &mut rx_buf, - )?; - - if !rx_phy.ends_with(&[0x90, 0x00]) { - return Err(PFError::Device("Failed to read config".into())); - } - - // Parse TLV - let mut config = AppConfig::default(); - let data = &rx_phy[..rx_phy.len() - 2]; - let mut i = 0; - while i < data.len() { - if i + 2 > data.len() { - break; - } - let tag_byte = data[i]; - let len = data[i + 1] as usize; - i += 2; - if i + len > data.len() { - break; - } - let val = &data[i..i + len]; - - if let Some(tag) = PhyTag::from_u8(tag_byte) { - match tag { - PhyTag::VidPid => { - if val.len() == 4 { - let vid = u16::from_be_bytes([val[0], val[1]]); - let pid = u16::from_be_bytes([val[2], val[3]]); - config.vid = format!("{:04X}", vid); - config.pid = format!("{:04X}", pid); - } - } - PhyTag::LedGpio => { - if !val.is_empty() { - config.led_gpio = val[0]; - } - } - PhyTag::LedBrightness => { - if !val.is_empty() { - config.led_brightness = val[0]; - } - } - PhyTag::PresenceTimeout => { - if !val.is_empty() { - config.touch_timeout = val[0]; - } - } - PhyTag::UsbProduct => { - let s = std::str::from_utf8(val) - .unwrap_or("") - .trim_matches(char::from(0)); - config.product_name = s.to_string(); - } - PhyTag::Opts => { - if val.len() >= 2 { - let opts_val = u16::from_be_bytes([val[0], val[1]]); - let opts = RescueOptions::from_bits_truncate(opts_val); - - config.led_dimmable = opts.contains(RescueOptions::LED_DIMMABLE); - config.power_cycle_on_reset = - !opts.contains(RescueOptions::DISABLE_POWER_RESET); - config.led_steady = opts.contains(RescueOptions::LED_STEADY); - } - } - PhyTag::Curves => { - if val.len() == 4 { - let curves_val = u32::from_be_bytes([val[0], val[1], val[2], val[3]]); - config.raw_curves_mask = Some(curves_val); - let curves = RescueCurves::from_bits_truncate(curves_val); - config.enable_secp256k1 = curves.contains(RescueCurves::SECP256K1); - } - } - PhyTag::LedDriver => { - if !val.is_empty() { - config.led_driver = Some(val[0]); - } - } - PhyTag::LedOrder => { - if !val.is_empty() { - config.led_order = Some(val[0]); - } - } - PhyTag::EnabledUsbItf => { - if !val.is_empty() { - config.enabled_usb_itf = Some(val[0]); - } - } - } - } - i += len; - } - - log::info!( - "Successfully read device details - Serial: {}, Firmware: {}.{}", - serial_str, - version_major, - version_minor - ); - - Ok(FullDeviceStatus { - info: DeviceInfo { - serial: serial_str, - flash_used: Some(used / 1024), - flash_total: Some(total / 1024), - firmware_version: format!("{}.{}", version_major, version_minor), - }, - config, - secure_boot: sb_enabled, - secure_lock: sb_locked, - method: DeviceMethod::Rescue, - firmware_type: fw_type, - }) + PcscTransport::open()?.read_device_details() } -/// Writes PHY configuration to the device via the Rescue Applet's WRITE command. -/// -/// Constructs a TLV (Tag-Length-Value) blob from the provided `AppConfigInput` fields and sends -/// it as a single APDU: `80 1C 01 00 [Lc] [TLV Data]`. Supported tags include: -/// - `0x00`: VID:PID (4 bytes, big-endian) -/// - `0x04`: LED GPIO pin -/// - `0x05`: LED brightness -/// - `0x08`: Touch/presence timeout -/// - `0x06`: Options bitmask (LED_DIMMABLE, DISABLE_POWER_RESET, LED_STEADY) -/// - `0x07`: Elliptic curves bitmask (SECP256K1, etc.) -/// - `0x0C`: LED driver selection -/// - `0x09`: USB product name (null-terminated) -/// - `0x0D`: LED order (RS-Key extension) -/// - `0x0B`: Enabled USB interfaces (CCID bit is always forced on for safety) -/// -/// # Returns -/// A success message string on `SW 9000`. -/// -/// # Errors -/// - `PFError::Io` if VID/PID are not valid hex strings -/// - `PFError::Device` if the WRITE APDU fails or returns a non-success status -/// - `PFError::Io` if the product name exceeds 32 bytes +/// Write PHY configuration to the device via the Rescue applet. pub fn write_config(config: AppConfigInput) -> Result { - log::info!("Writing configuration to device"); - log::debug!("Config input: {:?}", config); - - // 1. Construct TLV Blob - let mut tlv = Vec::new(); - - // VID:PID (Tag 0x00) - if let (Some(vid_str), Some(pid_str)) = (&config.vid, &config.pid) { - let vid = - u16::from_str_radix(vid_str, 16).map_err(|_| PFError::Io("Invalid VID".into()))?; - let pid = - u16::from_str_radix(pid_str, 16).map_err(|_| PFError::Io("Invalid PID".into()))?; - - tlv.push(PhyTag::VidPid as u8); - tlv.push(0x04); - tlv.write_u16::(vid).unwrap(); - tlv.write_u16::(pid).unwrap(); - } - - // LED GPIO (Tag 0x04) - if let Some(val) = config.led_gpio { - tlv.push(PhyTag::LedGpio as u8); - tlv.push(0x01); - tlv.push(val); - } - - // LED Brightness (Tag 0x05) - if let Some(val) = config.led_brightness { - tlv.push(PhyTag::LedBrightness as u8); - tlv.push(0x01); - tlv.push(val); - } - - // Touch Timeout (Tag 0x08) - if let Some(val) = config.touch_timeout { - tlv.push(PhyTag::PresenceTimeout as u8); - tlv.push(0x01); - tlv.push(val); - } - - // Options - if let (Some(dim), Some(cycle), Some(steady)) = ( - config.led_dimmable, - config.power_cycle_on_reset, - config.led_steady, - ) { - let mut opts = RescueOptions::empty(); - if dim { - opts.insert(RescueOptions::LED_DIMMABLE); - } - if !cycle { - opts.insert(RescueOptions::DISABLE_POWER_RESET); - } - if steady { - opts.insert(RescueOptions::LED_STEADY); - } - - tlv.push(PhyTag::Opts as u8); - tlv.push(0x02); - tlv.write_u16::(opts.bits()).unwrap(); - } - - // Curves - if config.enable_secp256k1.is_some() || config.raw_curves_mask.is_some() { - let mut mask = config.raw_curves_mask.unwrap_or(0); - if let Some(enabled) = config.enable_secp256k1 { - if enabled { - mask |= RescueCurves::SECP256K1.bits(); - } else { - mask &= !RescueCurves::SECP256K1.bits(); - } - } - tlv.push(PhyTag::Curves as u8); - tlv.push(0x04); - tlv.write_u32::(mask).unwrap(); - } - - // LED Driver (Tag 0x0C) - if let Some(val) = config.led_driver { - tlv.push(PhyTag::LedDriver as u8); - tlv.push(0x01); - tlv.push(val); - } - - // Product Name (Tag 0x09) - if let Some(name) = config.product_name.filter(|n| !n.is_empty()) { - let name_bytes = name.as_bytes(); - let len = name_bytes.len() + 1; - if len > 32 { - return Err(PFError::Io("Product name too long".into())); - } - - tlv.push(PhyTag::UsbProduct as u8); - tlv.push(len as u8); - tlv.extend_from_slice(name_bytes); - tlv.push(0x00); - } - - // LED Order (Tag 0x0D) — RS-Key extension, silently preserved - if let Some(val) = config.led_order { - tlv.push(PhyTag::LedOrder as u8); - tlv.push(0x01); - tlv.push(val); - } - - // Enabled USB Interfaces (Tag 0x0B) - if let Some(val) = config.enabled_usb_itf { - tlv.push(PhyTag::EnabledUsbItf as u8); - tlv.push(0x01); - // SAFETY: Never write a mask without CCID, otherwise Rescue applet is unreachable. - tlv.push(val | UsbInterfaces::CCID.bits()); - } - - // 2. Connect and Send - if tlv.is_empty() { - log::warn!("No configuration changes to apply"); - return Ok("No changes to apply".into()); - } - - log::debug!("TLV payload size: {} bytes", tlv.len()); - - let (card, _, _) = connect_and_select()?; - - // APDU: 80 1C 01 00 [Lc] [Data] - let mut apdu = vec![ - APDU_CLA_PROPRIETARY, - RescueInstruction::Write as u8, - WriteParam::PhyConfig as u8, - P2_UNUSED, - tlv.len() as u8, // Lc - ]; - apdu.extend_from_slice(&tlv); - - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if rx.ends_with(&[0x90, 0x00]) { - log::info!("Configuration applied successfully"); - Ok("Configuration Applied Successfully".into()) - } else { - log::error!("Configuration write failed: {:02X?}", rx); - Err(PFError::Device(format!("Write failed: {:02X?}", rx))) - } + PcscTransport::open()?.write_config(config) } -/// Reboots the device, optionally entering BOOTSEL (mass storage) mode for firmware updates. -/// -/// Sends a REBOOT APDU: `80 1B [P1] 00 00` where: -/// - `P1 = 0x00` (`RebootParam::Normal`): Reboots into normal FIDO mode -/// - `P1 = 0x01` (`RebootParam::Bootsel`): Reboots into BOOTSEL/UF2 bootloader mode -/// -/// # Arguments -/// * `to_bootsel` - If `true`, device enters UF2 bootloader mode for firmware flashing. -/// If `false`, device performs a normal reboot into FIDO mode. -/// -/// # Returns -/// A confirmation string if the reboot command was accepted. -/// -/// # Errors -/// - `PFError::Device` if the APDU fails or returns a non-success status +/// Reboot the device (normal or BOOTSEL mode) via the Rescue applet. pub fn reboot_device(to_bootsel: bool) -> Result { - let (card, _, _) = connect_and_select()?; - - let param = if to_bootsel { - RebootParam::Bootsel - } else { - RebootParam::Normal - }; - - let apdu = [ - APDU_CLA_PROPRIETARY, - RescueInstruction::Reboot as u8, - param as u8, - P2_UNUSED, - 0x00, - ]; - - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if rx.ends_with(&SW_SUCCESS) { - Ok("Reboot command sent".into()) - } else { - Err(PFError::Device(format!("Reboot failed: {:02X?}", rx))) - } + PcscTransport::open()?.reboot_device(to_bootsel) } -/// Enables or disables secure boot on the device. **UNSTABLE — work in progress.** -/// -/// Sends a SECURE APDU: `80 1D 00 [LockBool] 00` where: -/// - `LockBool = 0x01`: Enable and lock secure boot (irreversible on some firmware) -/// - `LockBool = 0x00`: Disable secure boot -/// -/// Uses pico-fido instruction `INS_SECURE` (0x1D). RS-Key uses `INS_OTP_LOCK` (0x1B) -/// for OTP fuse locking, which is a different operation. -/// -/// # Arguments -/// * `lock` - If `true`, enables secure boot with lock (may be irreversible). -/// -/// # Returns -/// A confirmation string if the secure boot command was accepted. -/// -/// # Errors -/// - `PFError::Device` if the APDU fails or returns a non-success status -/// -/// # Warning -/// This function is unstable and may change. Locking secure boot can permanently -/// prevent firmware downgrades. Use with caution. +/// Enable or lock secure boot via the Rescue applet. pub fn enable_secure_boot(lock: bool) -> Result { - let (card, _, _) = connect_and_select()?; - - // APDU: 80 1D [KeyIndex] [LockBool] 00 - // KeyIndex = 0 (Default), LockBool = 1 if true - let lock_byte = if lock { 0x01 } else { 0x00 }; - - let apdu = [ - APDU_CLA_PROPRIETARY, - RescueInstruction::Secure as u8, - 0x00, // Boot Key Index (0 = Default) - lock_byte as u8, - 0x00, - ]; - - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if rx.ends_with(&[0x90, 0x00]) { - Ok("Secure Boot Enabled".into()) - } else { - Err(PFError::Device(format!("Secure Boot failed: {:02X?}", rx))) - } + PcscTransport::open()?.enable_secure_boot(lock) } -// --- Vendor/LED Applet (RS-Key) --- - -/// Establishes a PC/SC connection and selects a specific vendor applet by AID. -/// -/// Unlike [`connect_and_select`] which selects the Rescue Applet, this function -/// selects an arbitrary applet (e.g., LED applet `F0 00 00 00 01` or Management applet). -/// Sends a SELECT APDU: `00 A4 04 00 [Len] [AID] 00`. -/// -/// # Arguments -/// * `aid` - The Application Identifier of the target applet (e.g., `VENDOR_LED_AID`, `MANAGEMENT_AID`) -/// -/// # Returns -/// An active `pcsc::Card` handle ready for APDU exchange with the selected applet. -/// -/// # Errors -/// - `PFError::NoDevice` if no smart card reader is found -/// - `PFError::Pcsc` if the PC/SC context cannot be established -/// - `PFError::Device` if the applet is not found (AID not recognized by firmware) -fn connect_and_select_aid(aid: &[u8]) -> Result { - let ctx = Context::establish(Scope::User).map_err(|e| { - log::error!("Failed to establish PCSC context: {}", e); - PFError::Pcsc(e) - })?; - - let mut readers_buf = [0; 2048]; - let mut readers = ctx.list_readers(&mut readers_buf)?; - let reader = readers.next().ok_or_else(|| { - log::info!("No Smart Card Reader found"); - PFError::NoDevice - })?; - - let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?; - - let mut apdu = vec![ - APDU_CLA_ISO, - APDU_INS_SELECT, - APDU_P1_SELECT_BY_DF_NAME, - 0x00, - aid.len() as u8, - ]; - apdu.extend_from_slice(aid); - apdu.push(0x00); - - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if !rx.ends_with(&[0x90, 0x00]) { - return Err(PFError::Device(format!( - "Applet not found (AID {:02X?})", - aid - ))); - } - - Ok(card) -} - -/// Reads the customized LED status configurations from the Vendor/LED applet. -/// -/// Communicates with the `F0 00 00 00 01` applet to retrieve a 9-byte configuration block -/// that dictates the color and brightness for each device state (idle, processing, touch, boot), -/// as well as the global 'steady' toggle flag. +/// Read LED status configuration from the vendor LED applet (RS-Key only). pub fn read_led_config() -> Result { - log::info!("Reading LED status config from Vendor/LED applet"); - let card = connect_and_select_aid(VENDOR_LED_AID)?; - - let apdu = [ - APDU_CLA_ISO, - VendorLedInstruction::GetLed as u8, - 0x00, - 0x00, - 0x00, - ]; - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if !rx.ends_with(&SW_SUCCESS) || rx.len() < 11 { - return Err(PFError::Device("Failed to read LED config".into())); - } - - let data = &rx[..rx.len() - 2]; - if data.len() < 9 { - return Err(PFError::Device("LED config response too short".into())); - } - - let steady = data[0] != 0; - let mut statuses = [(0u8, 0u8); 4]; - for s in 0..4 { - statuses[s] = (data[1 + 2 * s], data[2 + 2 * s]); - } - - log::info!("LED config: steady={}, statuses={:?}", steady, statuses); - Ok(LedStatusConfig { steady, statuses }) + PcscTransport::open_with_aid(constants::VENDOR_LED_AID)?.read_led_config() } -/// Applies an individual LED status update to the Vendor/LED applet. -/// -/// Constructs the APDU payload combining the targeted status index, color code, and global -/// steady flag into `P2`, with the brightness value in `P1`. The update is persisted to flash -/// and applied immediately. +/// Write a single LED status (color + brightness) via the vendor LED applet (RS-Key only). pub fn write_led_status( status: u8, color: u8, brightness: u8, steady: bool, ) -> Result { - log::info!( - "Setting LED: status={}, color={}, brightness={}, steady={}", - status, - color, - brightness, - steady - ); - let card = connect_and_select_aid(VENDOR_LED_AID)?; - - let steady_bit: u8 = if steady { 0x08 } else { 0x00 }; - let p2 = (color & 0x07) | steady_bit | ((status & 0x03) << 4); - - let apdu = [ - APDU_CLA_ISO, - VendorLedInstruction::SetLed as u8, - brightness, - p2, - ]; - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if rx.ends_with(&SW_SUCCESS) { - Ok("LED status updated".into()) - } else { - Err(PFError::Device(format!("SET LED failed: {:02X?}", rx))) - } + PcscTransport::open_with_aid(constants::VENDOR_LED_AID)? + .write_led_status(status, color, brightness, steady) } -// --- Management Applet (RS-Key) --- - -/// Retrieves the device management configuration mapping from the Management applet. -/// -/// Reads the active state of various USB interfaces (U2F, OATH, PIV, OpenPGP, etc.) to -/// determine which are supported by the hardware and which are currently enabled by the user. +/// Read USB interface configuration from the Management applet (RS-Key only). pub fn read_management_config() -> Result { - log::info!("Reading management config from Management applet"); - let card = connect_and_select_aid(MANAGEMENT_AID)?; - - let apdu = [ - APDU_CLA_ISO, - ManagementInstruction::ReadConfig as u8, - 0x00, - 0x00, - 0x00, - ]; - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if !rx.ends_with(&SW_SUCCESS) { - return Err(PFError::Device("Failed to read management config".into())); - } - - let data = &rx[..rx.len() - 2]; - if data.is_empty() { - return Err(PFError::Device("Empty management config response".into())); - } - - let overall_len = data[0] as usize; - let tlv_data = if data.len() > 1 + overall_len { - &data[1..1 + overall_len] - } else { - &data[1..] - }; - - let mut config = ManagementAppConfig::default(); - let mut i = 0; - while i < tlv_data.len() { - if i + 2 > tlv_data.len() { - break; - } - let tag = tlv_data[i]; - let len = tlv_data[i + 1] as usize; - i += 2; - if i + len > tlv_data.len() { - break; - } - let val = &tlv_data[i..i + len]; - match tag { - MGMT_TAG_USB_SUPPORTED => { - if val.len() >= 2 { - config.usb_supported = u16::from_be_bytes([val[0], val[1]]); - } - } - MGMT_TAG_USB_ENABLED => { - if val.len() >= 2 { - config.usb_enabled = u16::from_be_bytes([val[0], val[1]]); - } - } - _ => { - log::trace!("Management TLV tag 0x{:02X} skipped", tag); - } - } - i += len; - } - - log::info!( - "Management config: supported=0x{:04X}, enabled=0x{:04X}", - config.usb_supported, - config.usb_enabled - ); - Ok(config) + PcscTransport::open_with_aid(constants::MANAGEMENT_AID)?.read_management_config() } -/// Persists updated management endpoint configurations to the device. -/// -/// Overwrites the previously enabled interfaces with a new configuration bitmask. -/// For the changes to fully apply across all composite USB endpoints, a subsequent -/// device reboot or re-plug is required. +/// Write USB interface enable mask to the Management applet (RS-Key only). pub fn write_management_config(enabled_mask: u16) -> Result { - log::info!("Writing management config: enabled=0x{:04X}", enabled_mask); - let card = connect_and_select_aid(MANAGEMENT_AID)?; - - let inner = [ - MGMT_TAG_USB_ENABLED, - 0x02, - (enabled_mask >> 8) as u8, - (enabled_mask & 0xFF) as u8, - ]; - - let mut apdu = vec![ - APDU_CLA_ISO, - ManagementInstruction::WriteConfig as u8, - 0x00, - 0x00, - (inner.len() + 1) as u8, - inner.len() as u8, - ]; - apdu.extend_from_slice(&inner); - - let mut rx_buf = [0; 256]; - let rx = card.transmit(&apdu, &mut rx_buf)?; - - if rx.ends_with(&SW_SUCCESS) { - Ok("USB applications updated".into()) - } else { - Err(PFError::Device(format!( - "Management write failed: {:02X?}", - rx - ))) - } + PcscTransport::open_with_aid(constants::MANAGEMENT_AID)?.write_management_config(enabled_mask) } diff --git a/src/hal/rescue/ops.rs b/src/hal/rescue/ops.rs new file mode 100644 index 0000000..d17a6f2 --- /dev/null +++ b/src/hal/rescue/ops.rs @@ -0,0 +1,857 @@ +//! Rescue applet implementation for pico-fido and RS-Key firmware. +//! +//! ```text +//! rescue/ +//! ├── mod.rs — high-level rescue operations (read/write config, reboot, LED, management) +//! └── constants.rs — ISO 7816-4 constants, rescue instructions, PHY tags, vendor applets +//! ``` +//! +//! # What is the Rescue Applet? +//! +//! The Rescue applet is a low-level firmware recovery and hardware configuration +//! interface that operates independently of the FIDO2/CTAP2 stack. It provides +//! direct access to device hardware settings, flash memory, and security features +//! through a proprietary APDU-based protocol. +//! +//! Both [pico-fido](https://github.com/polhenarejos/pico-fido) (C) and +//! [RS-Key](https://github.com/TheMaxMur/RS-Key) (Rust) firmware implement +//! this applet with the same AID and command set. +//! +//! # Why is Rescue Mode Needed? +//! +//! FIDO2 devices expose a standardized interface (CTAP2) that abstracts away +//! hardware details. However, there are scenarios where direct hardware access +//! is required: +//! +//! - **Firmware recovery**: When FIDO mode is unresponsive or corrupted +//! - **Hardware configuration**: Changing USB VID/PID, LED settings, touch timeout +//! without requiring FIDO PIN authentication +//! - **Secure boot management**: Enabling/disabling secure boot, reading OTP status +//! - **Device provisioning**: Uploading attestation certificates, setting serial numbers +//! - **Firmware updates**: Rebooting into bootloader (BOOTSEL) mode for flashing +//! +//! The Rescue applet runs on the CCID (smart card) USB interface, which is always +//! available even when FIDO functionality is disabled or misconfigured. +//! +//! # Communication Protocol: PC/SC +//! +//! Unlike FIDO2 which uses USB HID (CTAPHID), the Rescue applet communicates via +//! **PC/SC** (Personal Computer/Smart Card) — the standard protocol for interacting +//! with smart card readers and ICCs (Integrated Circuit Cards). +//! +//! ```text +//! Host Application +//! │ +//! ▼ +//! pcsc-lite daemon (pcscd) ← Linux/macOS daemon +//! │ +//! ▼ +//! USB CCID Class Driver ← Smart card reader driver +//! │ +//! ▼ +//! Device CCID Interface ← Composite USB device +//! │ +//! ▼ +//! Rescue Applet (APDU commands) ← Firmware +//! ``` +//! +//! ## PC/SC Architecture +//! +//! The PC/SC specification defines a standard API for communicating with smart +//! cards. In our case, the RP2040/RP2350 device emulates a CCID-compliant smart +//! card reader with an embedded ICC. +//! +//! Key concepts: +//! - **Context**: A connection to the PC/SC daemon (establishes resource manager) +//! - **Reader**: A physical or virtual smart card reader (our device appears as one) +//! - **Card**: A connection to a specific card in a reader +//! - **APDU**: Application Protocol Data Unit — the command/response format +//! +//! ## APDU Command Structure +//! +//! ```text +//! ┌─────┬─────┬─────┬─────┬─────┬─────────────┐ +//! │ CLA │ INS │ P1 │ P2 │ Lc │ Data │ +//! └─────┴─────┴─────┴─────┴─────┴─────────────┘ +//! 1B 1B 1B 1B 0-1B 0-255 bytes +//! ``` +//! +//! - **CLA** (0x80 for Rescue): Command class — proprietary extension +//! - **INS**: Instruction code (e.g., 0x1E for READ, 0x1C for WRITE) +//! - **P1/P2**: Parameters (sub-command selectors) +//! - **Lc**: Length of data field +//! - **Data**: Command payload +//! +//! Response ends with Status Words (SW1 SW2): +//! - `0x90 0x00`: Success +//! - `0x6A 0x82`: File/application not found +//! - `0x69 0x82`: Security status not satisfied +//! +//! # Data Flow +//! +//! ```text +//! io::read_device_details() +//! │ +//! ▼ +//! rescue::read_device_details() ← this file +//! │ +//! ▼ +//! connect_and_select() ← PC/SC connection + applet selection +//! │ +//! ▼ +//! card.transmit(apdu) ← ISO 7816-4 APDU exchange +//! │ +//! ▼ +//! PC/SC (CCID USB interface) +//! ``` +//! +//! ## Applet Selection +//! +//! Every session begins with applet selection: +//! +//! ```text +//! APDU: 00 A4 04 04 08 A0 58 3F C1 9B 7E 4F 21 +//! ── ── ── ── ── ───────────────────────── +//! CLA INS P1 P2 Len AID (Rescue Applet) +//! ``` +//! +//! The SELECT response contains device identity: +//! - Byte 0: MCU type (1=RP2350, 2=ESP32-S3, etc.) +//! - Byte 1: Product type (2=FIDO) +//! - Byte 2: SDK version major +//! - Byte 3: SDK version minor +//! - Bytes 4-11: Serial number (8 bytes) +//! +//! # Module Structure +//! +//! [`constants`](crate::hal::rescue::constants) defines all protocol constants shared between pico-fido and RS-Key: +//! - ISO 7816-4 command bytes (CLA, INS, P1, P2, SW) +//! - Rescue instruction codes and parameters +//! - PHY configuration tags and bitflags +//! - Vendor applet AIDs and instructions (LED, Management) +//! +//! This module contains the public functions called from [`io`](crate::hal::io): +//! - `read_device_details()`: Reads full device status via Rescue +//! - `write_config()`: Writes PHY configuration (VID/PID, LED, curves, etc.) +//! - `reboot_device()`: Reboots device (normal or BOOTSEL mode) +//! - `enable_secure_boot()`: Enables secure boot (WIP) +//! - `read_led_config()` / `write_led_status()`: LED color configuration (RS-Key) +//! - `read_management_config()` / `write_management_config()`: USB interface config (RS-Key) +//! +//! # Firmware Differences +//! +//! | Feature | pico-fido | RS-Key | +//! |---------|-----------|--------| +//! | Language | C | Rust | +//! | Rescue AID | `A0 58 3F C1 9B 7E 4F 21` | Same | +//! | Secure Boot | `INS_SECURE` (0x1D) | `INS_OTP_LOCK` (0x1B) — irreversible | +//! | LED Applet | Not available | Available (AID: `F0 00 00 00 01`) | +//! | Management | Not available | Available (Yubico-compatible) | +//! | Anti-rollback | Not available | Available (OTP fuses) | +//! +//! # References +//! +//! - [pico-fido Rescue](https://github.com/polhenarejos/pico-fido/blob/main/src/rescue.c) +//! - [RS-Key Rescue](https://github.com/TheMaxMur/RS-Key/blob/main/crates/rsk-rescue/src/lib.rs) +//! - [PC/SC Specification](https://pcsc1groupwg.readthedocs.io/) +//! - [ISO 7816-4](https://www.iso.org/standard/74873.html) +//! - [CCID Specification](https://www.usb.org/document-library/class-specification-12-chip-smart-card-interface) + +use crate::error::PFError; +use crate::hal::transport::pcsc::PcscTransport; +use crate::hal::{rescue::constants::*, types::*}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; + +use std::io::Cursor; + +/// APDU-level rescue operations implemented on the PC/SC transport. +/// +/// Each method builds the appropriate ISO 7816-4 command APDU, transmits +/// it via [`PcscTransport::transmit`], and parses the response. Multiple +/// applets are available (Rescue, LED, Management) depending on firmware. +pub trait RescueOperations { + /// Read full device status (info, config, security flags) via the Rescue applet. + fn read_device_details(&self) -> Result; + /// Write PHY configuration (VID/PID, LED, curves, etc.) via the Rescue applet. + fn write_config(&self, config: AppConfigInput) -> Result; + /// Reboot the device — either normally or into BOOTSEL (firmware-update) mode. + fn reboot_device(&self, to_bootsel: bool) -> Result; + /// Enable or lock secure boot on the device (WIP / firmware-specific). + fn enable_secure_boot(&self, lock: bool) -> Result; + /// Read LED status configuration from the vendor LED applet (RS-Key only). + fn read_led_config(&self) -> Result; + /// Write a single LED status (color + brightness) via the LED applet (RS-Key only). + fn write_led_status( + &self, + status: u8, + color: u8, + brightness: u8, + steady: bool, + ) -> Result; + /// Read USB interface configuration from the Management applet (RS-Key only). + fn read_management_config(&self) -> Result; + /// Write USB interface enable mask to the Management applet (RS-Key only). + fn write_management_config(&self, enabled_mask: u16) -> Result; +} + +impl RescueOperations for PcscTransport { + /// Reads comprehensive device details including identity, flash usage, secure boot status, and PHY configuration. + /// + /// Performs three sequential APDU operations after applet selection: + /// 1. SELECT response is parsed for MCU type, firmware version, and serial number + /// 2. `READ(FlashInfo)` — reads flash usage statistics (free, used, total) + /// 3. `READ(SecureBootStatus)` — reads secure boot enable/lock state + /// 4. `READ(PhyConfig)` — reads TLV-encoded hardware configuration (VID/PID, LED, curves, etc.) + /// + /// # Returns + /// A `FullDeviceStatus` struct containing device info, parsed PHY config, and secure boot state. + /// + /// # Errors + /// - `PFError::Device` if the SELECT response is malformed or any READ command fails + /// - `PFError::NoDevice` if no reader is available + fn read_device_details(&self) -> Result { + log::info!("Reading full device details"); + let select_resp = &self.select_resp; + let fw_type = &self.firmware_type; + + log::info!("Select Response: {:?}", select_resp); + + // FIX: Relax the length check. + // Minimum valid response is 4 bytes data + 2 bytes SW = 6 bytes. + if select_resp.len() < 6 { + log::error!("Invalid select response length: {}", select_resp.len()); + return Err(PFError::Device("Invalid select response".into())); + } + + let version_major = select_resp[2]; + let version_minor = select_resp[3]; + + // FIX: Handle missing Serial Number safely + // If the firmware sends 14 bytes, we have a serial. If it sends 6, we don't. + let serial_str = if select_resp.len() >= 14 { + hex::encode_upper(&select_resp[4..12]) + } else { + log::warn!( + "Device did not return a Serial Number (Firmware mismatch?). Using placeholder." + ); + "00000000".to_string() + }; + + log::info!("Device Version: {}.{}", version_major, version_minor); + log::info!("Device Serial: {}", serial_str); + + // 2. Read Flash Info + let mut rx_buf = [0; 256]; + let rx_flash = self.transmit( + &[ + APDU_CLA_PROPRIETARY, + RescueInstruction::Read as u8, + ReadParam::FlashInfo as u8, + P2_UNUSED, + 0x00, // Le + ], + &mut rx_buf, + )?; + + if !rx_flash.ends_with(&SW_SUCCESS) { + return Err(PFError::Device("Failed to read flash".into())); + } + + let mut rdr = Cursor::new(&rx_flash[..rx_flash.len() - 2]); + let _free = rdr.read_u32::().unwrap_or(0); + let used = rdr.read_u32::().unwrap_or(0); + let total = rdr.read_u32::().unwrap_or(0); + + // NOTE: captured but currently unused variables + let _nfiles = rdr.read_u32::().unwrap_or(0); + let _chip_size = rdr.read_u32::().unwrap_or(0); + + // --- Read Secure Boot Status --- + let rx_secure = self.transmit( + &[ + APDU_CLA_PROPRIETARY, + RescueInstruction::Read as u8, + ReadParam::SecureBootStatus as u8, + P2_UNUSED, + 0x00, + ], + &mut rx_buf, + )?; + + let (sb_enabled, sb_locked) = if rx_secure.ends_with(&[0x90, 0x00]) && rx_secure.len() >= 4 + { + (rx_secure[0] != 0, rx_secure[1] != 0) + } else { + (false, false) + }; // --- Read PHY Config --- + let rx_phy = self.transmit( + &[ + APDU_CLA_PROPRIETARY, + RescueInstruction::Read as u8, + ReadParam::PhyConfig as u8, + 0x01, + 0x00, + ], + &mut rx_buf, + )?; + + if !rx_phy.ends_with(&[0x90, 0x00]) { + return Err(PFError::Device("Failed to read config".into())); + } + + // Parse TLV + let mut config = AppConfig::default(); + let data = &rx_phy[..rx_phy.len() - 2]; + let mut i = 0; + while i < data.len() { + if i + 2 > data.len() { + break; + } + let tag_byte = data[i]; + let len = data[i + 1] as usize; + i += 2; + if i + len > data.len() { + break; + } + let val = &data[i..i + len]; + + if let Some(tag) = PhyTag::from_u8(tag_byte) { + match tag { + PhyTag::VidPid => { + if val.len() == 4 { + let vid = u16::from_be_bytes([val[0], val[1]]); + let pid = u16::from_be_bytes([val[2], val[3]]); + config.vid = format!("{:04X}", vid); + config.pid = format!("{:04X}", pid); + } + } + PhyTag::LedGpio => { + if !val.is_empty() { + config.led_gpio = val[0]; + } + } + PhyTag::LedBrightness => { + if !val.is_empty() { + config.led_brightness = val[0]; + } + } + PhyTag::PresenceTimeout => { + if !val.is_empty() { + config.touch_timeout = val[0]; + } + } + PhyTag::UsbProduct => { + let s = std::str::from_utf8(val) + .unwrap_or("") + .trim_matches(char::from(0)); + config.product_name = s.to_string(); + } + PhyTag::Opts => { + if val.len() >= 2 { + let opts_val = u16::from_be_bytes([val[0], val[1]]); + let opts = RescueOptions::from_bits_truncate(opts_val); + + config.led_dimmable = opts.contains(RescueOptions::LED_DIMMABLE); + config.power_cycle_on_reset = + !opts.contains(RescueOptions::DISABLE_POWER_RESET); + config.led_steady = opts.contains(RescueOptions::LED_STEADY); + } + } + PhyTag::Curves => { + if val.len() == 4 { + let curves_val = u32::from_be_bytes([val[0], val[1], val[2], val[3]]); + config.raw_curves_mask = Some(curves_val); + let curves = RescueCurves::from_bits_truncate(curves_val); + config.enable_secp256k1 = curves.contains(RescueCurves::SECP256K1); + } + } + PhyTag::LedDriver => { + if !val.is_empty() { + config.led_driver = Some(val[0]); + } + } + PhyTag::LedOrder => { + if !val.is_empty() { + config.led_order = Some(val[0]); + } + } + PhyTag::LedNum => { + if !val.is_empty() { + config.led_num = Some(val[0]); + } + } + PhyTag::EnabledUsbItf => { + if !val.is_empty() { + config.enabled_usb_itf = Some(val[0]); + } + } + } + } + i += len; + } + + log::info!( + "Successfully read device details - Serial: {}, Firmware: {}.{}", + serial_str, + version_major, + version_minor + ); + + Ok(FullDeviceStatus { + info: DeviceInfo { + serial: serial_str, + flash_used: Some(used / 1024), + flash_total: Some(total / 1024), + firmware_version: format!("{}.{}", version_major, version_minor), + }, + config, + secure_boot: sb_enabled, + secure_lock: sb_locked, + method: DeviceMethod::Rescue, + firmware_type: fw_type.clone(), + }) + } + + /// Writes PHY configuration to the device via the Rescue Applet's WRITE command. + /// + /// Constructs a TLV (Tag-Length-Value) blob from the provided `AppConfigInput` fields and sends + /// it as a single APDU: `80 1C 01 00 [Lc] [TLV Data]`. Supported tags include: + /// - `0x00`: VID:PID (4 bytes, big-endian) + /// - `0x04`: LED GPIO pin + /// - `0x05`: LED brightness + /// - `0x08`: Touch/presence timeout + /// - `0x06`: Options bitmask (LED_DIMMABLE, DISABLE_POWER_RESET, LED_STEADY) + /// - `0x07`: Elliptic curves bitmask (SECP256K1, etc.) + /// - `0x0C`: LED driver selection + /// - `0x09`: USB product name (null-terminated) + /// - `0x0D`: LED order (RS-Key extension) + /// - `0x0B`: Enabled USB interfaces (CCID bit is always forced on for safety) + /// + /// # Returns + /// A success message string on `SW 9000`. + /// + /// # Errors + /// - `PFError::Io` if VID/PID are not valid hex strings + /// - `PFError::Device` if the WRITE APDU fails or returns a non-success status + /// - `PFError::Io` if the product name exceeds 32 bytes + fn write_config(&self, config: AppConfigInput) -> Result { + log::info!("Writing configuration to device"); + log::debug!("Config input: {:?}", config); + + // 1. Construct TLV Blob + let mut tlv = Vec::new(); + + // VID:PID (Tag 0x00) + if let (Some(vid_str), Some(pid_str)) = (&config.vid, &config.pid) { + let vid = + u16::from_str_radix(vid_str, 16).map_err(|_| PFError::Io("Invalid VID".into()))?; + let pid = + u16::from_str_radix(pid_str, 16).map_err(|_| PFError::Io("Invalid PID".into()))?; + + tlv.push(PhyTag::VidPid as u8); + tlv.push(0x04); + tlv.write_u16::(vid).unwrap(); + tlv.write_u16::(pid).unwrap(); + } + + // LED GPIO (Tag 0x04) + if let Some(val) = config.led_gpio { + tlv.push(PhyTag::LedGpio as u8); + tlv.push(0x01); + tlv.push(val); + } + + // LED Brightness (Tag 0x05) + if let Some(val) = config.led_brightness { + tlv.push(PhyTag::LedBrightness as u8); + tlv.push(0x01); + tlv.push(val); + } + + // Touch Timeout (Tag 0x08) + if let Some(val) = config.touch_timeout { + tlv.push(PhyTag::PresenceTimeout as u8); + tlv.push(0x01); + tlv.push(val); + } + + // Options + if let (Some(dim), Some(cycle), Some(steady)) = ( + config.led_dimmable, + config.power_cycle_on_reset, + config.led_steady, + ) { + let mut opts = RescueOptions::empty(); + if dim { + opts.insert(RescueOptions::LED_DIMMABLE); + } + if !cycle { + opts.insert(RescueOptions::DISABLE_POWER_RESET); + } + if steady { + opts.insert(RescueOptions::LED_STEADY); + } + + tlv.push(PhyTag::Opts as u8); + tlv.push(0x02); + tlv.write_u16::(opts.bits()).unwrap(); + } + + // Curves + if config.enable_secp256k1.is_some() || config.raw_curves_mask.is_some() { + let mut mask = config.raw_curves_mask.unwrap_or(0); + if let Some(enabled) = config.enable_secp256k1 { + if enabled { + mask |= RescueCurves::SECP256K1.bits(); + } else { + mask &= !RescueCurves::SECP256K1.bits(); + } + } + tlv.push(PhyTag::Curves as u8); + tlv.push(0x04); + tlv.write_u32::(mask).unwrap(); + } + + // LED Driver (Tag 0x0C) + if let Some(val) = config.led_driver { + tlv.push(PhyTag::LedDriver as u8); + tlv.push(0x01); + tlv.push(val); + } + + // Product Name (Tag 0x09) + if let Some(name) = config.product_name.filter(|n| !n.is_empty()) { + let name_bytes = name.as_bytes(); + let len = name_bytes.len() + 1; + if len > 32 { + return Err(PFError::Io("Product name too long".into())); + } + + tlv.push(PhyTag::UsbProduct as u8); + tlv.push(len as u8); + tlv.extend_from_slice(name_bytes); + tlv.push(0x00); + } + + // LED Order (Tag 0x0D) — RS-Key extension, silently preserved + if let Some(val) = config.led_order { + tlv.push(PhyTag::LedOrder as u8); + tlv.push(0x01); + tlv.push(val); + } + + // Enabled USB Interfaces (Tag 0x0B) + if let Some(val) = config.enabled_usb_itf { + tlv.push(PhyTag::EnabledUsbItf as u8); + tlv.push(0x01); + // SAFETY: Never write a mask without CCID, otherwise Rescue applet is unreachable. + tlv.push(val | UsbInterfaces::CCID.bits()); + } + + // 2. Connect and Send + if tlv.is_empty() { + log::warn!("No configuration changes to apply"); + return Ok("No changes to apply".into()); + } + + log::debug!("TLV payload size: {} bytes", tlv.len()); + + // APDU: 80 1C 01 00 [Lc] [Data] + let mut apdu = vec![ + APDU_CLA_PROPRIETARY, + RescueInstruction::Write as u8, + WriteParam::PhyConfig as u8, + P2_UNUSED, + tlv.len() as u8, // Lc + ]; + apdu.extend_from_slice(&tlv); + + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if rx.ends_with(&[0x90, 0x00]) { + log::info!("Configuration applied successfully"); + Ok("Configuration Applied Successfully".into()) + } else { + log::error!("Configuration write failed: {:02X?}", rx); + Err(PFError::Device(format!("Write failed: {:02X?}", rx))) + } + } + + /// Reboots the device, optionally entering BOOTSEL (mass storage) mode for firmware updates. + /// + /// Sends a REBOOT APDU: `80 1B [P1] 00 00` where: + /// - `P1 = 0x00` (`RebootParam::Normal`): Reboots into normal FIDO mode + /// - `P1 = 0x01` (`RebootParam::Bootsel`): Reboots into BOOTSEL/UF2 bootloader mode + /// + /// # Arguments + /// * `to_bootsel` - If `true`, device enters UF2 bootloader mode for firmware flashing. + /// If `false`, device performs a normal reboot into FIDO mode. + /// + /// # Returns + /// A confirmation string if the reboot command was accepted. + /// + /// # Errors + /// - `PFError::Device` if the APDU fails or returns a non-success status + #[allow(dead_code)] + fn reboot_device(&self, to_bootsel: bool) -> Result { + let param = if to_bootsel { + RebootParam::Bootsel + } else { + RebootParam::Normal + }; + + let apdu = [ + APDU_CLA_PROPRIETARY, + RescueInstruction::Reboot as u8, + param as u8, + P2_UNUSED, + 0x00, + ]; + + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if rx.ends_with(&SW_SUCCESS) { + Ok("Reboot command sent".into()) + } else { + Err(PFError::Device(format!("Reboot failed: {:02X?}", rx))) + } + } + + /// Enables or disables secure boot on the device. **UNSTABLE — work in progress.** + /// + /// Sends a SECURE APDU: `80 1D 00 [LockBool] 00` where: + /// - `LockBool = 0x01`: Enable and lock secure boot (irreversible on some firmware) + /// - `LockBool = 0x00`: Disable secure boot + /// + /// Uses pico-fido instruction `INS_SECURE` (0x1D). RS-Key uses `INS_OTP_LOCK` (0x1B) + /// for OTP fuse locking, which is a different operation. + /// + /// # Arguments + /// * `lock` - If `true`, enables secure boot with lock (may be irreversible). + /// + /// # Returns + /// A confirmation string if the secure boot command was accepted. + /// + /// # Errors + /// - `PFError::Device` if the APDU fails or returns a non-success status + /// + /// # Warning + /// This function is unstable and may change. Locking secure boot can permanently + /// prevent firmware downgrades. Use with caution. + #[allow(dead_code)] + fn enable_secure_boot(&self, lock: bool) -> Result { + // APDU: 80 1D [KeyIndex] [LockBool] 00 + // KeyIndex = 0 (Default), LockBool = 1 if true + let lock_byte = if lock { 0x01 } else { 0x00 }; + + let apdu = [ + APDU_CLA_PROPRIETARY, + RescueInstruction::Secure as u8, + 0x00, // Boot Key Index (0 = Default) + lock_byte as u8, + 0x00, + ]; + + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if rx.ends_with(&[0x90, 0x00]) { + Ok("Secure Boot Enabled".into()) + } else { + Err(PFError::Device(format!("Secure Boot failed: {:02X?}", rx))) + } + } + + // --- Vendor/LED Applet (RS-Key) --- + + /// Reads the customized LED status configurations from the Vendor/LED applet. + /// + /// Communicates with the `F0 00 00 00 01` applet to retrieve a 9-byte configuration block + /// that dictates the color and brightness for each device state (idle, processing, touch, boot), + /// as well as the global 'steady' toggle flag. + fn read_led_config(&self) -> Result { + log::info!("Reading LED status config from Vendor/LED applet"); + + let apdu = [ + APDU_CLA_ISO, + VendorLedInstruction::GetLed as u8, + 0x00, + 0x00, + 0x00, + ]; + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if !rx.ends_with(&SW_SUCCESS) || rx.len() < 11 { + return Err(PFError::Device("Failed to read LED config".into())); + } + + let data = &rx[..rx.len() - 2]; + if data.len() < 9 { + return Err(PFError::Device("LED config response too short".into())); + } + + let steady = data[0] != 0; + let mut statuses = [(0u8, 0u8); 4]; + for s in 0..4 { + statuses[s] = (data[1 + 2 * s], data[2 + 2 * s]); + } + + log::info!("LED config: steady={}, statuses={:?}", steady, statuses); + Ok(LedStatusConfig { steady, statuses }) + } + + /// Applies an individual LED status update to the Vendor/LED applet. + /// + /// Constructs the APDU payload combining the targeted status index, color code, and global + /// steady flag into `P2`, with the brightness value in `P1`. The update is persisted to flash + /// and applied immediately. + fn write_led_status( + &self, + status: u8, + color: u8, + brightness: u8, + steady: bool, + ) -> Result { + log::info!( + "Setting LED: status={}, color={}, brightness={}, steady={}", + status, + color, + brightness, + steady + ); + // Assuming transport is already connected to LED applet via open_with_aid + + let steady_bit: u8 = if steady { 0x08 } else { 0x00 }; + let p2 = (color & 0x07) | steady_bit | ((status & 0x03) << 4); + + let apdu = [ + APDU_CLA_ISO, + VendorLedInstruction::SetLed as u8, + brightness, + p2, + ]; + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if rx.ends_with(&SW_SUCCESS) { + Ok("LED status updated".into()) + } else { + Err(PFError::Device(format!("SET LED failed: {:02X?}", rx))) + } + } + + // --- Management Applet (RS-Key) --- + + /// Retrieves the device management configuration mapping from the Management applet. + /// + /// Reads the active state of various USB interfaces (U2F, OATH, PIV, OpenPGP, etc.) to + /// determine which are supported by the hardware and which are currently enabled by the user. + fn read_management_config(&self) -> Result { + log::info!("Reading management config from Management applet"); + + let apdu = [ + APDU_CLA_ISO, + ManagementInstruction::ReadConfig as u8, + 0x00, + 0x00, + 0x00, + ]; + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if !rx.ends_with(&SW_SUCCESS) { + return Err(PFError::Device("Failed to read management config".into())); + } + + let data = &rx[..rx.len() - 2]; + if data.is_empty() { + return Err(PFError::Device("Empty management config response".into())); + } + + let overall_len = data[0] as usize; + let tlv_data = if data.len() > 1 + overall_len { + &data[1..1 + overall_len] + } else { + &data[1..] + }; + + let mut config = ManagementAppConfig::default(); + let mut i = 0; + while i < tlv_data.len() { + if i + 2 > tlv_data.len() { + break; + } + let tag = tlv_data[i]; + let len = tlv_data[i + 1] as usize; + i += 2; + if i + len > tlv_data.len() { + break; + } + let val = &tlv_data[i..i + len]; + match tag { + MGMT_TAG_USB_SUPPORTED => { + if val.len() >= 2 { + config.usb_supported = u16::from_be_bytes([val[0], val[1]]); + } + } + MGMT_TAG_USB_ENABLED => { + if val.len() >= 2 { + config.usb_enabled = u16::from_be_bytes([val[0], val[1]]); + } + } + _ => { + log::trace!("Management TLV tag 0x{:02X} skipped", tag); + } + } + i += len; + } + + log::info!( + "Management config: supported=0x{:04X}, enabled=0x{:04X}", + config.usb_supported, + config.usb_enabled + ); + Ok(config) + } + + /// Persists updated management endpoint configurations to the device. + /// + /// Overwrites the previously enabled interfaces with a new configuration bitmask. + /// For the changes to fully apply across all composite USB endpoints, a subsequent + /// device reboot or re-plug is required. + fn write_management_config(&self, enabled_mask: u16) -> Result { + log::info!("Writing management config: enabled=0x{:04X}", enabled_mask); + + let inner = [ + MGMT_TAG_USB_ENABLED, + 0x02, + (enabled_mask >> 8) as u8, + (enabled_mask & 0xFF) as u8, + ]; + + let mut apdu = vec![ + APDU_CLA_ISO, + ManagementInstruction::WriteConfig as u8, + 0x00, + 0x00, + (inner.len() + 1) as u8, + inner.len() as u8, + ]; + apdu.extend_from_slice(&inner); + + let mut rx_buf = [0; 256]; + let rx = self.transmit(&apdu, &mut rx_buf)?; + + if rx.ends_with(&SW_SUCCESS) { + Ok("USB applications updated".into()) + } else { + Err(PFError::Device(format!( + "Management write failed: {:02X?}", + rx + ))) + } + } +} diff --git a/src/hal/transport/fido.rs b/src/hal/transport/fido.rs new file mode 100644 index 0000000..d6851a0 --- /dev/null +++ b/src/hal/transport/fido.rs @@ -0,0 +1,551 @@ +//! USB HID transport for CTAP2/FIDO2 communication. +//! +//! # What is HID? +//! +//! USB HID (Human Interface Device) is a standard USB device class for input +//! devices like keyboards, mice, and gamepads. HID devices communicate through +//! *reports* — fixed-size packets sent/received on USB endpoints. The OS +//! auto-detects HID devices without requiring custom drivers, making it ideal +//! for FIDO2 security keys that need to work across platforms. +//! +//! # What is CTAPHID? +//! +//! CTAPHID is the [CTAP2] transport binding for USB HID. It layers the CTAP2 +//! protocol on top of HID reports, allowing FIDO2 authenticators to +//! communicate with hosts through the standard HID driver stack. The +//! specification is defined in [CTAP2 §11.2](https://fidoalliance.org/specs/fido-v2.3-ps-20260226/fido-client-to-authenticator-protocol-v2.3-ps-20260226.html#usb-human-interface-device-hid). +//! +//! # Framing protocol +//! +//! CTAPHID uses 64-byte HID reports. Messages that exceed 64 bytes are split +//! across multiple packets: +//! +//! ```text +//! Init Packet (64 bytes): +//! CID(4) | CMD(1) | BCNT_HI(1) | BCNT_LO(1) | payload[..57] +//! +//! Continuation Packets: +//! CID(4) | SEQ(1) | payload[..59] +//! ``` +//! +//! - **CID** (Channel ID): 4-byte identifier negotiated via `CTAPHID_INIT`. +//! Multiplexes multiple logical channels on one HID device. +//! - **CMD**: Command byte (e.g., `0x90` for CBOR, `0x86` for INIT). +//! - **BCNT**: 16-bit big-endian payload length. +//! - **SEQ**: Sequence number for continuation packets (starts at 0). +//! +//! # Channel initialization +//! +//! Before any CTAP2 command can be sent, the host must negotiate a Channel ID: +//! +//! 1. Host sends `CTAPHID_INIT` to the broadcast CID (`0xFFFFFFFF`) with a +//! random 8-byte nonce. +//! 2. Device responds with the same nonce and a newly allocated CID. +//! 3. All subsequent communication uses this CID. +//! +//! This allows multiple CTAP2 sessions to coexist on one device (e.g., two +//! browsers open simultaneously). +//! +//! # Cryptographic operations +//! +//! PIN operations require ECDH key agreement and AES-256-CBC encryption: +//! +//! ```text +//! 1. Host → Device: GetKeyAgreement (returns device's P-256 public key) +//! 2. Host generates ephemeral P-256 key pair +//! 3. Host computes ECDH shared secret → SHA-256(shared_secret) +//! 4. PIN hash encrypted with AES-256-CBC (key = shared_secret, IV = 0) +//! 5. Token decrypted with same key +//! ``` +//! +//! The shared secret is derived as `SHA-256(ECDH_x_coordinate)`. +//! +//! # Firmware compatibility +//! +//! Both [pico-fido] and [RS-Key] implement CTAPHID. This module handles: +//! - Standard CTAP2 commands (GetInfo, MakeCredential, GetAssertion, etc.) +//! - Pico-fido vendor commands (`0xC1`, `0xC2`) for hardware config +//! - RS-Key vendor command (`0x41`) for seed backup and attestation +//! +//! # File structure +//! +//! - [`HidTransport`] — main transport struct; opens HID device, negotiates +//! CID, sends/receives CBOR payloads +//! - [`EnumerateRpResponse`](crate::hal::fido::ops::EnumerateRpResponse), +//! [`EnumerateCredentialResponse`](crate::hal::fido::ops::EnumerateCredentialResponse) — response +//! types for credential management enumeration +//! - PIN methods (`get_pin_token`, `set_pin`, `change_pin`) implement the +//! full ECDH + AES-CBC flow per CTAP2 §11.5.4 +//! - Vendor methods (`send_vendor_config`, `get_enterprise_attestation_csr`) +//! handle pico-fido/RS-Key specific extensions +//! +//! [CTAP2]: https://fidoalliance.org/specs/fido-v2.3-ps-20260226/fido-client-to-authenticator-protocol-v2.3-ps-20260226.html +//! [pico-fido]: https://github.com/polhenarejos/pico-fido +//! [RS-Key]: https://github.com/TheMaxMur/RS-Key + +use rand::RngExt; +use std::time::Duration; + +use crate::error::PFError; + +/// Size of a single USB HID report in bytes (CTAP2 §11.2 mandates 64-byte reports). +const HID_REPORT_SIZE: usize = 64; + +/// FIDO Alliance HID Usage Page identifier. +/// +/// Devices advertising this usage page in their HID descriptor are identified +/// as FIDO authenticators by the operating system's HID enumeration. +const HID_USAGE_PAGE_FIDO: u16 = 0xF1D0; + +/// Broadcast Channel ID used for the initial CTAPHID_INIT handshake. +/// +/// The host sends an INIT command to this CID to request a unique Channel ID +/// from the authenticator. All subsequent communication uses the negotiated CID. +const CTAPHID_CID_BROADCAST: u32 = 0xFFFFFFFF; + +/// CTAPHID INIT command byte (0x86). +/// +/// Initiates channel negotiation. The host sends a random 8-byte nonce; the +/// device responds with the same nonce and a newly allocated Channel ID. +const CTAPHID_INIT: u8 = 0x86; + +/// CTAPHID CBOR command byte (0x90). +/// +/// Wraps a CTAP2 CBOR-encoded command or response payload. The payload is +/// fragmented across one init packet and zero or more continuation packets. +pub const CTAPHID_CBOR: u8 = 0x90; + +/// CTAPHID ERROR response byte (0xBF). +/// +/// Indicates the authenticator encountered an error processing the command. +/// The next byte contains the CTAP2 error code. +const CTAPHID_ERROR: u8 = 0xBF; + +/// CTAPHID KEEPALIVE status byte (0xBB). +/// +/// Sent by the authenticator while processing a long-running operation (e.g., +/// MakeCredential with user interaction). The host must continue reading +/// until it receives the final CBOR or ERROR response. +const CTAPHID_KEEPALIVE: u8 = 0xBB; + +/// Default timeout in milliseconds for draining stale HID packets. +const HID_READ_TIMEOUT_MS: i32 = 10; + +/// Timeout in milliseconds for reading the CTAPHID_INIT response during channel negotiation. +const HID_INIT_READ_TIMEOUT_MS: i32 = 100; + +/// Timeout in milliseconds for reading a single HID response packet (excluding keepalives). +const HID_RESP_READ_TIMEOUT_MS: i32 = 2000; + +/// Timeout in milliseconds for reading CTAPHID continuation packets. +const HID_CONT_READ_TIMEOUT_MS: i32 = 500; + +/// Maximum total time in milliseconds allowed for a complete CBOR command/response exchange. +const HID_TOTAL_TIMEOUT_MS: i32 = 5000; + +/// USB HID transport for CTAP2/FIDO2 communication. +/// +/// Wraps a `hidapi::HidDevice` and manages the CTAPHID framing layer: +/// channel negotiation (INIT), multi-packet CBOR send/receive, keepalive +/// handling, and all higher-level CTAP2 operations (PIN, credential management, +/// vendor commands). +/// +/// Created via [`HidTransport::open`], which scans for a device with the FIDO +/// HID Usage Page (0xF1D0) and performs the INIT handshake to obtain a Channel ID. +#[derive(Debug)] +pub struct HidTransport { + device: hidapi::HidDevice, + cid: u32, + pub vid: u16, + pub pid: u16, + pub product_name: String, +} + +impl HidTransport { + /// Open the first available FIDO HID device and negotiate a Channel ID. + /// + /// Scans for a device with HID Usage Page `0xF1D0`, opens it, and performs + /// the CTAPHID_INIT handshake. Returns an error if no device is found or + /// the INIT handshake times out. + pub fn open() -> Result { + log::info!("Attempting to open HID transport for FIDO device..."); + let api = hidapi::HidApi::new().map_err(|e| { + log::error!("Failed to initialize HidApi: {}", e); + PFError::Device(format!("Failed to initialize HidApi: {}", e)) + })?; + + // Find device with FIDO Usage Page (0xF1D0) + let info = api + .device_list() + .find(|d| d.usage_page() == HID_USAGE_PAGE_FIDO) + .ok_or_else(|| { + log::warn!("No FIDO device found with Usage Page 0xF1D0."); + PFError::NoDevice + })?; + + log::debug!( + "Found FIDO device: VendorID=0x{:04X}, ProductID=0x{:04X}", + info.vendor_id(), + info.product_id() + ); + + let vid = info.vendor_id(); + let pid = info.product_id(); + let product_name = info + .product_string() + .unwrap_or("Unknown FIDO Device") + .to_string(); + + let device = info.open_device(&api).map_err(|e| { + log::error!("Failed to open HID device: {}", e); + PFError::Device(format!("Failed to open HID device: {}", e)) + })?; + + // Negotiate Channel ID (CID) + let cid = Self::init_channel(&device).map_err(|e| { + log::error!("Failed to negotiate Channel ID: {}", e); + PFError::Device(format!("Failed to negotiate Channel ID: {}", e)) + })?; + + log::info!("HID Transport established successfully. CID: 0x{:08X}", cid); + Ok(Self { + device, + cid, + vid, + pid, + product_name, + }) + } + + /// Negotiate a CTAPHID Channel ID via CTAPHID_INIT. + /// + /// Sends an INIT command to the broadcast CID (`0xFFFFFFFF`) with a random + /// 8-byte nonce, then reads the response to extract the allocated CID. + /// Drains any stale packets before the handshake to avoid confusion. + fn init_channel(device: &hidapi::HidDevice) -> Result { + log::debug!("Initializing CTAPHID channel..."); + + let mut drain_buf = [0u8; HID_REPORT_SIZE]; + while let Ok(n) = device.read_timeout(&mut drain_buf[..], HID_READ_TIMEOUT_MS) { + if n == 0 { + break; + } + log::trace!("Drained stale HID packet: {:02X?}", &drain_buf[0..16]); + } + + let mut nonce = [0u8; 8]; + rand::rng().fill(&mut nonce); + + // Construct Init Packet: [CID(4) | CMD(1) | LEN(2) | NONCE(8)] + let mut report = [0u8; HID_REPORT_SIZE + 1]; // +1 for Report ID (always 0) + report[1..5].copy_from_slice(&CTAPHID_CID_BROADCAST.to_be_bytes()); + report[5] = CTAPHID_INIT; + report[6] = 0; // Len MSB + report[7] = 8; // Len LSB + report[8..16].copy_from_slice(&nonce); + + log::trace!("Sending CTAPHID_INIT broadcast with nonce: {:02X?}", nonce); + device.write(&report[..]).map_err(|e| { + log::error!("Failed to write INIT packet: {}", e); + PFError::Io(format!("Failed to write INIT packet: {}", e)) + })?; + + // Read Response until we find our nonce + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(1) { + let mut buf = [0u8; HID_REPORT_SIZE]; + if device + .read_timeout(&mut buf[..], HID_INIT_READ_TIMEOUT_MS) + .is_ok() + { + // Check if response matches our broadcast and nonce + if buf[0..4] == CTAPHID_CID_BROADCAST.to_be_bytes() + && buf[4] == CTAPHID_INIT + && buf[7..15] == nonce + { + // New CID is at bytes 16..20 + let new_cid = u32::from_be_bytes([buf[15], buf[16], buf[17], buf[18]]); + log::debug!("Channel negotiation successful. New CID: 0x{:08X}", new_cid); + return Ok(new_cid); + } else { + log::trace!( + "Received ignoreable HID packet during CID negotiation: {:02X?}", + &buf[0..16] + ); + } + } + } + log::error!("Timeout waiting for CTAPHID_INIT response."); + Err(PFError::Device( + "Timeout waiting for FIDO Init response".into(), + )) + } + + /// Send a CTAP2 CBOR command and wait for the response using the default timeout. + /// + /// Convenience wrapper around [`send_cbor_with_timeout`](HidTransport::send_cbor_with_timeout). + pub fn send_cbor(&self, cmd: u8, payload: &[u8]) -> Result, PFError> { + self.send_cbor_with_timeout(cmd, payload, HID_TOTAL_TIMEOUT_MS) + } + + /// Send a CTAP2 CBOR command and wait for the response with a custom timeout. + /// + /// Fragments `payload` into CTAPHID init + continuation packets, then reads + /// and reassembles the response. The `timeout_ms` parameter overrides the + /// default for the read phase (useful for operations that require user interaction). + pub fn send_cbor_with_timeout( + &self, + cmd: u8, + payload: &[u8], + timeout_ms: i32, + ) -> Result, PFError> { + self.write_cbor_request(cmd, payload)?; + self.read_cbor_response(cmd, timeout_ms) + } + + /// Send a CTAP2 CBOR command and return the raw HID response without status-byte parsing. + /// + /// Unlike [`send_cbor`](HidTransport::send_cbor), this does not check the CTAP status byte + /// or strip it from the response. Useful for vendor commands that return non-standard payloads. + pub fn send_raw(&self, cmd: u8, payload: &[u8]) -> Result, PFError> { + self.write_cbor_request(cmd, payload)?; + self.read_hid_response(cmd, HID_TOTAL_TIMEOUT_MS) + } + + /// Send the CTAP authenticatorReset command (0x07). + /// + /// Resets the authenticator to its factory state: all credentials, PINs, + /// and configuration are erased. Uses a 30-second timeout to allow for + /// any required user interaction (e.g., touch confirmation). + pub fn reset(&self) -> Result<(), PFError> { + log::info!("Sending CTAP authenticatorReset (0x07)..."); + self.write_cbor_request(CTAPHID_CBOR, &[0x07])?; + self.read_cbor_response(CTAPHID_CBOR, 30_000)?; + Ok(()) + } + + /// Fragment and write a CTAPHID request to the device. + /// + /// Encodes the command byte and payload into a CTAPHID init packet followed + /// by zero or more continuation packets, then writes each 65-byte HID report + /// (1 byte Report ID + 64 bytes payload) to the device. + fn write_cbor_request(&self, cmd: u8, payload: &[u8]) -> Result<(), PFError> { + log::debug!( + "Sending CBOR Command: 0x{:02X}, Payload Size: {} bytes", + cmd, + payload.len() + ); + + let total_len = payload.len(); + let mut sent = 0; + let mut sequence = 0u8; + + // 1. Init Packet + let mut report = [0u8; HID_REPORT_SIZE + 1]; + report[1..5].copy_from_slice(&self.cid.to_be_bytes()); + report[5] = cmd; + report[6] = (total_len >> 8) as u8; + report[7] = (total_len & 0xFF) as u8; + + let to_copy = std::cmp::min(total_len, HID_REPORT_SIZE - 7); + report[8..8 + to_copy].copy_from_slice(&payload[0..to_copy]); + sent += to_copy; + + // log::trace!("Writing Init Packet (Sent: {}/{})", sent, total_len); + if let Err(e) = self.device.write(&report[..]) { + log::error!("Failed to write initial HID packet: {}", e); + return Err(PFError::Io(format!( + "Failed to write initial HID packet: {}", + e, + ))); + } else { + log::trace!("Successfully sent initial HID packet"); + } + + // 2. Continuation Packets + while sent < total_len { + let mut report = [0u8; HID_REPORT_SIZE + 1]; + report[1..5].copy_from_slice(&self.cid.to_be_bytes()); + report[5] = 0x7F & sequence; // SEQ + sequence += 1; + + let to_copy = std::cmp::min(total_len - sent, HID_REPORT_SIZE - 5); + report[6..6 + to_copy].copy_from_slice(&payload[sent..sent + to_copy]); + sent += to_copy; + + // log::trace!("Writing Cont Packet Seq {} (Sent: {}/{})", sequence - 1, sent, total_len); + if let Err(e) = self.device.write(&report[..]) { + log::error!( + "Failed to write continuation HID packet (Seq {}): {}", + sequence - 1, + e + ); + return Err(PFError::Io(format!( + "Failed to write continuation HID packet: {}", + e, + ))); + } else { + log::trace!( + "Successfully sent continuation HID packet (Seq {})", + sequence - 1 + ); + } + } + + Ok(()) + } + + /// Read a CTAPHID response and verify the CTAP status byte. + /// + /// Delegates to [`read_hid_response`](HidTransport::read_hid_response) for packet + /// reassembly, then checks the first byte for a non-zero CTAP status code and + /// strips it before returning the payload. + fn read_cbor_response(&self, cmd: u8, timeout_ms: i32) -> Result, PFError> { + let response_data = self.read_hid_response(cmd, timeout_ms)?; + + // Check CTAP Status Byte (First byte of payload) + if response_data.is_empty() { + log::error!("Device sent empty payload response."); + return Err(PFError::Device("Empty response".into())); + } + let status = response_data[0]; + if status != 0x00 { + log::error!("FIDO Operation returned failure status: 0x{:02X}", status); + return Err(PFError::Device(format!( + "FIDO Operation Failed with Status: 0x{:02X}", + status + ))); + } + + log::debug!( + "Command 0x{:02X} successful. Response payload len: {}", + cmd, + response_data.len() - 1 + ); + // Return payload without status byte + Ok(response_data[1..].to_vec()) + } + + /// Read and reassemble a CTAPHID response from the device. + /// + /// Handles the full CTAPHID receive flow: + /// 1. Reads the init packet while skipping KEEPALIVE and mismatched-CID packets. + /// 2. Validates the command byte matches the expected response. + /// 3. Reads continuation packets in sequence order until the full payload is received. + /// 4. Enforces the `timeout_ms` deadline across the entire read. + fn read_hid_response(&self, cmd: u8, timeout_ms: i32) -> Result, PFError> { + log::debug!("Waiting for response..."); + + let mut buf = [0u8; HID_REPORT_SIZE]; + let mut response_data = Vec::new(); + let expected_len: usize; + let mut read_len = 0; + let mut last_seq = 0; + + let start_time = std::time::Instant::now(); + let timeout_duration = std::time::Duration::from_millis(timeout_ms as u64); + + // 1. Read First Packet (Loop to handle Keepalives) + loop { + if start_time.elapsed() > timeout_duration { + log::error!("Timeout waiting for device response (Keepalive limit exceeded)"); + return Err(PFError::Device( + "Timeout waiting for device response (Keepalive limit exceeded)".into(), + )); + } + + if let Err(e) = self + .device + .read_timeout(&mut buf[..], HID_RESP_READ_TIMEOUT_MS) + { + log::error!("Timeout reading response packet: {}", e); + return Err(PFError::Io(format!( + "Timeout reading response packet: {}", + e + ))); + } + + // Check CID mismatch + if u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) != self.cid { + log::warn!("Received packet from different CID, ignoring..."); + continue; + } + + // Check for KEEPALIVE (0xBB) + if buf[4] == CTAPHID_KEEPALIVE { + let status = buf[5]; // Keepalive status byte + log::debug!( + "Device sent KEEPALIVE (Status: 0x{:02X}), waiting...", + status + ); + continue; // Go back to start of loop and read again + } + + // If we are here, it's a real response + break; + } + + if buf[4] == CTAPHID_ERROR { + log::error!("Device returned CTAP Error code: 0x{:02X}", buf[5]); + return Err(PFError::Device(format!( + "Device returned CTAP Error: 0x{:02X}", + buf[5], + ))); + } else { + log::trace!("Packet received is not a CTAP Error"); + } + + if buf[4] == cmd { + expected_len = u16::from_be_bytes([buf[5], buf[6]]) as usize; + let in_pkt = std::cmp::min(expected_len, HID_REPORT_SIZE - 7); + response_data.extend_from_slice(&buf[7..7 + in_pkt]); + read_len += in_pkt; + // log::trace!("Received Init Response. Expecting {} bytes total.", expected_len); + } else { + log::error!( + "Unexpected command response: 0x{:02X} (Expected 0x{:02X})", + buf[4], + cmd + ); + return Err(PFError::Device(format!( + "Unexpected command response: 0x{:02X} (Expected 0x{:02X})", + buf[4], cmd + ))); + } + + // 2. Read Continuation Packets + while read_len < expected_len { + if let Err(e) = self + .device + .read_timeout(&mut buf[..], HID_CONT_READ_TIMEOUT_MS) + { + log::error!("Timeout reading continuation packet: {}", e); + return Err(PFError::Io(format!( + "Timeout reading continuation packet: {}", + e + ))); + } + + if u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) != self.cid { + continue; // Ignore packets from other channels + } + + let seq = buf[4]; + if seq != last_seq { + log::error!( + "Sequence mismatch in response. Expected {}, got {}", + last_seq, + seq + ); + return Err(PFError::Device("Sequence mismatch".into())); + } + last_seq += 1; + + let in_pkt = std::cmp::min(expected_len - read_len, HID_REPORT_SIZE - 5); + response_data.extend_from_slice(&buf[5..5 + in_pkt]); + read_len += in_pkt; + } + + Ok(response_data) + } +} diff --git a/src/hal/transport/mod.rs b/src/hal/transport/mod.rs new file mode 100644 index 0000000..3914213 --- /dev/null +++ b/src/hal/transport/mod.rs @@ -0,0 +1,131 @@ +//! Device discovery and transport abstraction. +//! +//! Two physical transports coexist: +//! +//! * **FIDO HID** ([`fido::HidTransport`]) — the primary CTAP2 / CTAPHID channel +//! over USB HID. Used for normal operations (credential management, PIN, +//! authentication). Supports both pico-fido and RS-Key firmwares. +//! * **Rescue PC/SC** ([`pcsc::PcscTransport`]) — an ISO 7816-4 APDU channel over +//! a PC/SC smart-card reader. Used when the device is in rescue/bootloader mode +//! or when FIDO commands are blocked (e.g. firmware version ≥ 7.4 on pico-fido). +//! +//! The [`DeviceHandle::discover`] method tries FIDO HID first and falls back to +//! PC/SC. This ensures normal operation prefers the faster HID path while still +//! allowing rescue access when needed. + +use std::fmt; + +use crate::error::PFError; +use crate::hal::types::FirmwareType; + +pub mod fido; +use fido::HidTransport; + +pub mod pcsc; +use pcsc::PcscTransport; + +/// A connected device handle over either the FIDO or rescue transport. +pub enum DeviceHandle { + /// Connected via CTAPHID (USB HID). + Fido(HidTransport), + /// Connected via PC/SC (ISO 7816-4 APDU, rescue/bootloader mode). + Rescue(PcscTransport), +} + +impl fmt::Debug for DeviceHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fido(t) => f.debug_tuple("Fido").field(t).finish(), + Self::Rescue(t) => f.debug_tuple("Rescue").field(&t.firmware_type).finish(), + } + } +} + +/// Opaque device identity presented to consumers after discovery. +/// +/// vid/pid are populated only for the FIDO HID path; the rescue path +/// reports (0, 0) since PC/SC does not expose USB identifiers. +#[derive(Debug)] +#[allow(dead_code)] +pub struct DeviceIdentity { + /// USB Vendor ID (0 for rescue/PC/SC). + pub vid: u16, + /// USB Product ID (0 for rescue/PC/SC). + pub pid: u16, + /// Human-readable product name. + pub product_name: String, + /// Detected firmware type (unknown for FIDO HID until GetInfo is called). + pub firmware_type: FirmwareType, +} + +impl DeviceHandle { + /// Return the firmware type for a rescue handle, or `Unknown` for FIDO. + pub fn firmware_type(&self) -> FirmwareType { + match self { + Self::Fido(_) => FirmwareType::Unknown, + Self::Rescue(t) => t.firmware_type.clone(), + } + } + + /// Extract the inner FIDO transport, consuming the handle. + #[allow(dead_code)] + pub fn into_fido(self) -> Option { + match self { + Self::Fido(t) => Some(t), + _ => None, + } + } + + /// Try to discover a device via FIDO HID first, falling back to Rescue PC/SC. + #[allow(dead_code)] + pub fn discover() -> Result<(Self, DeviceIdentity), PFError> { + match Self::try_fido() { + Ok(Some((handle, identity))) => { + log::info!("Device discovered via FIDO HID transport"); + return Ok((handle, identity)); + } + Ok(None) => log::info!("No FIDO HID device found"), + Err(e) => log::warn!("FIDO HID discovery error: {}", e), + } + + match Self::try_rescue() { + Ok(Some((handle, identity))) => { + log::info!("Device discovered via Rescue PC/SC transport"); + return Ok((handle, identity)); + } + Ok(None) => log::info!("No Rescue PC/SC device found"), + Err(e) => log::warn!("Rescue PC/SC discovery error: {}", e), + } + + Err(PFError::NoDevice) + } + + /// Try to connect via FIDO HID transport. + pub fn try_fido() -> Result, PFError> { + let transport = HidTransport::open()?; + let identity = DeviceIdentity { + vid: transport.vid, + pid: transport.pid, + product_name: transport.product_name.clone(), + firmware_type: FirmwareType::Unknown, + }; + Ok(Some((Self::Fido(transport), identity))) + } + + /// Try to connect via Rescue PC/SC transport. + pub fn try_rescue() -> Result, PFError> { + match PcscTransport::open() { + Ok(transport) => { + let identity = DeviceIdentity { + vid: 0, + pid: 0, + product_name: "Rescue Device".into(), + firmware_type: transport.firmware_type.clone(), + }; + Ok(Some((Self::Rescue(transport), identity))) + } + Err(PFError::NoDevice) => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/src/hal/transport/pcsc.rs b/src/hal/transport/pcsc.rs new file mode 100644 index 0000000..62bb255 --- /dev/null +++ b/src/hal/transport/pcsc.rs @@ -0,0 +1,96 @@ +//! PC/SC (Smart Card) transport for the rescue channel. +//! +//! Communicates with the device via ISO 7816-4 APDUs over a PC/SC +//! compatible smart-card reader. The device exposes a rescue applet +//! identified by [`RESCUE_AID`] when in rescue/bootloader mode. + +use crate::error::PFError; +use crate::hal::{rescue::constants::*, types::FirmwareType}; +use pcsc::{Context, Protocols, Scope, ShareMode}; + +/// PC/SC transport wrapping a connected ISO 7816-4 smart card. +pub struct PcscTransport { + /// The connected PC/SC card handle. + pub card: pcsc::Card, + /// Firmware type determined during the SELECT AID exchange. + pub firmware_type: FirmwareType, + /// Raw response bytes from the SELECT AID command. + pub select_resp: Vec, +} + +impl PcscTransport { + /// Open the rescue channel using the default Rescue AID. + pub fn open() -> Result { + Self::open_with_aid(RESCUE_AID) + } + + /// Open the rescue channel using a custom AID. + /// + /// Scans for the first connected reader, sends the SELECT AID APDU, + /// and determines the firmware type from the reader name or response data. + pub fn open_with_aid(aid: &[u8]) -> Result { + let ctx = Context::establish(Scope::User).map_err(|e| { + log::error!("Failed to establish PCSC context: {}", e); + PFError::Pcsc(e) + })?; + + let mut readers_buf = [0; 2048]; + let mut readers = ctx.list_readers(&mut readers_buf)?; + + let reader = readers.next().ok_or_else(|| { + log::info!("No Smart Card Reader found"); + PFError::NoDevice + })?; + + let reader_name = reader.to_string_lossy(); + let mut fw_type = if reader_name.contains("RS-Key") || reader_name.contains("RSK") { + FirmwareType::RSKey + } else { + FirmwareType::Unknown + }; + + let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?; + + let mut apdu = vec![ + APDU_CLA_ISO, + APDU_INS_SELECT, + APDU_P1_SELECT_BY_DF_NAME, + APDU_P2_RETURN_FCI, + aid.len() as u8, + ]; + apdu.extend_from_slice(aid); + + let mut rx_buf = [0; 256]; + let rx = card.transmit(&apdu, &mut rx_buf)?; + + if !rx.ends_with(&[0x90, 0x00]) { + log::error!("Rescue Applet not found on the device!"); + return Err(PFError::Device( + "Rescue Applet not found on device. Is it in FIDO mode?".into(), + )); + } + + let data = rx.to_vec(); + + if fw_type == FirmwareType::Unknown { + if data.len() >= 4 && data[2] >= 8 { + fw_type = FirmwareType::RSKey; + } else { + fw_type = FirmwareType::PicoFido; + } + } + + log::info!("Successfully connected to Rescue Applet"); + log::info!("Detected firmware type: {:?}", fw_type); + + Ok(Self { + card, + firmware_type: fw_type, + select_resp: data, + }) + } + + pub fn transmit<'a>(&self, apdu: &[u8], rx_buf: &'a mut [u8]) -> Result<&'a [u8], PFError> { + self.card.transmit(apdu, rx_buf).map_err(PFError::Pcsc) + } +} diff --git a/src/hal/types.rs b/src/hal/types.rs index 031748e..f6031bb 100644 --- a/src/hal/types.rs +++ b/src/hal/types.rs @@ -34,21 +34,31 @@ pub struct AppConfig { pub vid: String, pub pid: String, pub product_name: String, + /// GPIO pin the status LED is connected to. pub led_gpio: u8, pub led_brightness: u8, + /// Touch-button press timeout in seconds. pub touch_timeout: u8, + /// LED driver type identifier (e.g. PWM direct vs external driver). #[serde(skip_serializing_if = "Option::is_none")] pub led_driver: Option, pub led_dimmable: bool, pub power_cycle_on_reset: bool, + /// When set, the LED stays on (not pulsed) for touch/processing states. pub led_steady: bool, pub enable_secp256k1: bool, + /// Bitmask of raw (unwrapped) curve identifiers supported by the firmware. #[serde(skip_serializing_if = "Option::is_none")] pub raw_curves_mask: Option, + /// The order in which LED colours are sequenced during status transitions. #[serde(skip_serializing_if = "Option::is_none")] pub led_order: Option, + /// Bitmask of USB interface endpoints that are enabled. #[serde(skip_serializing_if = "Option::is_none")] pub enabled_usb_itf: Option, + /// Number of individual LEDs on the device. + #[serde(skip_serializing_if = "Option::is_none")] + pub led_num: Option, } /// Partial config update; `None` fields are left unchanged on the device. @@ -69,25 +79,34 @@ pub struct AppConfigInput { pub raw_curves_mask: Option, pub led_order: Option, pub enabled_usb_itf: Option, + pub led_num: Option, } /// Aggregated snapshot of device info, config, and security state. #[derive(Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct FullDeviceStatus { + /// Basic device identity and flash usage. pub info: DeviceInfo, + /// Full device configuration (USB descriptors, LED, touch, crypto). pub config: AppConfig, + /// Whether secure boot is enabled on the device. pub secure_boot: bool, + /// Whether the device's secure configuration is locked (read-only until reset). pub secure_lock: bool, + /// Protocol channel used for the last successful communication. pub method: DeviceMethod, + /// Detected firmware variant. pub firmware_type: FirmwareType, } /// Protocol channel used to communicate with the device. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub enum DeviceMethod { + /// Communication over FIDO HID (CTAPHID / CTAP2). #[serde(rename = "FIDO")] Fido, + /// Communication over PC/SC rescue channel (ISO 7816-4 APDU). Rescue, } @@ -95,8 +114,13 @@ pub enum DeviceMethod { /// compatibility checks throughout the application. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] pub enum FirmwareType { + /// Pol Henarejos' pico-fido / pico-keys-sdk based firmware. PicoFido, + /// TheMaxMur's RS-Key firmware (SDK 5.x+). RSKey, + /// LibreKeys LK-ONE (pico-fido fork, same AAGUID as pico-fido). + LkOne, + /// Unrecognised or undetected firmware. #[default] Unknown, } @@ -106,6 +130,7 @@ impl fmt::Display for FirmwareType { match self { Self::PicoFido => write!(f, "pico-fido"), Self::RSKey => write!(f, "RS-Key"), + Self::LkOne => write!(f, "LK-ONE"), Self::Unknown => write!(f, "Unknown"), } } @@ -118,7 +143,10 @@ impl fmt::Display for FirmwareType { /// device status: Idle, Processing, Touch, Boot. #[derive(Serialize, Debug, Default, Clone, PartialEq)] pub struct LedStatusConfig { + /// Whether the LED stays on steady (true) or pulses (false). pub steady: bool, + /// Fixed array of `(color, brightness)` pairs indexed by device status: + /// Idle, Processing, Touch, Boot. pub statuses: [(u8, u8); 4], } @@ -137,14 +165,20 @@ pub struct ManagementAppConfig { #[derive(Serialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct FidoDeviceInfo { + /// Supported CTAP versions reported by the authenticator. pub versions: Vec, + /// Supported CTAP extensions. pub extensions: Vec, + /// Authenticator Attestation GUID (hex-encoded, uppercase, no dashes). pub aaguid: String, + /// Authenticator options map from `authenticatorGetInfo`. pub options: std::collections::HashMap, pub max_msg_size: i128, + /// PIN/UV protocol versions supported. pub pin_protocols: Vec, pub remaining_discoverable_credentials: Option, pub min_pin_length: i128, + /// Firmware version as reported by the authenticator (may differ from the HAL-parsed version). pub firmware_version: String, /// Supported vendor config commands (human-readable names), parsed from CTAP GetInfo. pub vendor_config_commands: Vec, @@ -152,6 +186,7 @@ pub struct FidoDeviceInfo { pub certifications: std::collections::HashMap, pub max_credential_count_in_list: Option, pub max_credential_id_length: Option, + /// List of supported COSE algorithm display names. pub algorithms: Vec, pub max_serialized_large_blob_array: Option, pub force_pin_change: Option, @@ -176,3 +211,8 @@ pub struct StoredCredential { pub const RSKEY_AAGUID: &str = "2479C7BF6B3056839EC80E8171A918B7"; /// AAGUID assigned to Pico-Fido hardware. pub const PICOFIDO_AAGUID: &str = "89FB94B706C936739B7E30526D968145"; +/// AAGUID assigned to LibreKeys LK-ONE hardware (same as pico-fido fork). +pub const LKONE_AAGUID: &str = "89FB94B706C936739B7E30526D968145"; +/// LibreKeys USB VID:PID allocated by OpenMoko. +pub const LKONE_VID: u16 = 0x1D50; +pub const LKONE_PID: u16 = 0x619B; diff --git a/src/logging.rs b/src/logging.rs index 3d8cdab..4528466 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,3 +1,11 @@ +//! Logging initialisation with log4rs. +//! +//! Sets up a rolling-file appender (10 MB per file, delete-oldest policy) +//! and a console appender. The `picoforge` logger defaults to `Trace` in +//! debug builds and `Info` in release builds; verbose third-party +//! loggers (`gpui`, `gpui_component`, `blade_graphics`) are capped at +//! `Error` to reduce noise. + use directories::ProjectDirs; use log::LevelFilter; use log4rs::{ diff --git a/src/main.rs b/src/main.rs index 58c3d01..fa43509 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![deny(missing_docs)] + //! # PicoForge //! //! An open-source commissioning and management tool for **Pico FIDO** hardware security keys. @@ -144,16 +146,29 @@ //! │ ├── error.rs # Application-wide error types (PFError) //! │ ├── logging.rs # log4rs configuration //! │ ├── hal/ # Hardware abstraction layer -//! │ │ ├── mod.rs # Module root, re-exports -//! │ │ ├── io.rs # High-level API bridging rescue and FIDO +//! │ │ ├── mod.rs # Module root +//! │ │ ├── io.rs # High-level dispatch across protocols //! │ │ ├── types.rs # Shared data structures -//! │ │ ├── rescue/ # Rescue applet (PC/SC protocol) +//! │ │ ├── common/ # COSE enums, version parsing //! │ │ │ ├── mod.rs -//! │ │ │ └── constants.rs -//! │ │ └── fido/ # FIDO2/CTAP2 protocol +//! │ │ │ ├── cose.rs +//! │ │ │ └── version.rs +//! │ │ ├── firmwares/ # Per-firmware capability gating +//! │ │ │ ├── mod.rs +//! │ │ │ ├── picofido.rs +//! │ │ │ └── rskey.rs +//! │ │ ├── transport/ # Physical transport abstractions +//! │ │ │ ├── mod.rs +//! │ │ │ ├── fido.rs # CTAPHID over USB HID +//! │ │ │ └── pcsc.rs # ISO 7816-4 over PC/SC +//! │ │ ├── fido/ # FIDO2/CTAP2 protocol +//! │ │ │ ├── mod.rs +//! │ │ │ ├── constants.rs +//! │ │ │ └── ops.rs # PIN, credential mgmt, vendor cmds +//! │ │ └── rescue/ # Rescue applet (PC/SC APDU) //! │ │ ├── mod.rs -//! │ │ ├── constants.rs -//! │ │ └── hid.rs # USB HID transport +//! │ │ ├── constants.rs # ISO 7816-4, PHY tags, vendor AIDs +//! │ │ └── ops.rs # Device config, reboot, LED, mgmt //! │ └── ui/ # GPUI frontend //! │ ├── mod.rs //! │ ├── app.rs # ApplicationRoot, AppModels, layout, Render @@ -332,7 +347,7 @@ //! ### Key Design Principles //! //! 1. **HAL Gateway Pattern**: Views and ViewModels never import `crate::hal`. -//! [`DeviceRepo`] is the sole bridge — it re-exports all needed types and +//! [`DeviceRepo`](crate::ui::models::device::DeviceRepo) is the sole bridge — it re-exports all needed types and //! provides `*_blocking()` static methods for background tasks. //! //! 2. **Protocol Abstraction**: The `io.rs` layer provides a unified API that diff --git a/src/ui/app.rs b/src/ui/app.rs index 27e26a2..137d8e0 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -37,6 +37,7 @@ pub struct ViewModelStore { } impl ViewModelStore { + /// Create an empty view-model store. pub fn new() -> Self { Self { home: None, diff --git a/src/ui/assets.rs b/src/ui/assets.rs index bff2b52..bbf83b1 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -1,3 +1,9 @@ +//! Embedded asset loader for SVG icons used throughout the application. +//! +//! Uses `rust-embed` to bundle the contents of `static/icons/` into the +//! binary at compile time. The [`Assets`] struct is registered as a GPUI +//! `SharedString` source so that components can reference icons by filename. + #[allow(unused)] use anyhow::anyhow; use gpui::*; diff --git a/src/ui/colors.rs b/src/ui/colors.rs index 1f44991..aa1aa05 100644 --- a/src/ui/colors.rs +++ b/src/ui/colors.rs @@ -1,5 +1,11 @@ +//! Zinc color palette constants used throughout the UI layer. +//! +//! These map to the `picoforge-zinc.json` theme file and are referenced +//! by name — never by hex value — in all component and screen code. + // NOTE: This module is work in progress and more colors will be added. Also I am thinking of storing these as HSLA instead of u32 RGB values. // Reference: https://ui.shadcn.com/colors +/// Zinc gray-scale palette constants (50–950). pub mod zinc { #![allow(unused)] pub const ZINC50: u32 = 0xfafafa; diff --git a/src/ui/components/button.rs b/src/ui/components/button.rs index 6672629..a9f8d2d 100644 --- a/src/ui/components/button.rs +++ b/src/ui/components/button.rs @@ -1,3 +1,5 @@ +//! Reusable styled button and icon-button components. + #![allow(unused)] use crate::ui::colors; diff --git a/src/ui/components/card.rs b/src/ui/components/card.rs index 6f8af78..cdd9095 100644 --- a/src/ui/components/card.rs +++ b/src/ui/components/card.rs @@ -1,8 +1,11 @@ +//! Reusable card component for grouping related content with an optional header. + #![allow(unused)] use gpui::*; use gpui_component::{ActiveTheme, Icon, Theme, h_flex, v_flex}; +/// A titled card panel with optional description, icon, and header-right slot. #[derive(IntoElement)] pub struct Card { title: Option, diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index a0ad1f0..f795178 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1,3 +1,5 @@ +//! Modal dialog components for PIN prompts, confirmations, and status display. + use gpui::*; use gpui_component::{ ActiveTheme, Disableable, Sizable, WindowExt, @@ -24,6 +26,7 @@ enum DialogPhase { Error(String), } +/// Dialog content for collecting the FIDO PIN from the user. pub struct PinPromptContent { phase: DialogPhase, title: SharedString, @@ -36,16 +39,24 @@ pub struct PinPromptContent { } impl PinPromptContent { + /// Transition the dialog to a loading state with the given message. + pub fn set_loading_msg(&mut self, msg: impl Into, cx: &mut Context) { + self.phase = DialogPhase::LoadingWithMessage(msg.into()); + cx.notify(); + } + fn set_loading(&mut self, cx: &mut Context) { self.phase = DialogPhase::Loading; cx.notify(); } + /// Transition the dialog to a success state. pub fn set_success(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Success(msg); cx.notify(); } + /// Transition the dialog to an error state with the given message. pub fn set_error(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Error(msg); cx.notify(); @@ -95,23 +106,29 @@ impl Render for PinPromptContent { ) .into_any_element(), - DialogPhase::Loading | DialogPhase::LoadingWithMessage(_) => v_flex() - .gap_4() - .child(self.description.clone()) - .child(Input::new(&self.pin_input).disabled(true)) - .child( - h_flex() - .justify_end() - .gap_2() - .child(Button::new("cancel").label("Cancel").disabled(true)) - .child( - Button::new("confirm") - .primary() - .label("Loading...") - .loading(true), - ), - ) - .into_any_element(), + DialogPhase::Loading | DialogPhase::LoadingWithMessage(_) => { + let text = match &self.phase { + DialogPhase::LoadingWithMessage(msg) => msg.clone(), + _ => self.description.to_string(), + }; + v_flex() + .gap_4() + .child(text) + .child(Input::new(&self.pin_input).disabled(true)) + .child( + h_flex() + .justify_end() + .gap_2() + .child(Button::new("cancel").label("Cancel").disabled(true)) + .child( + Button::new("confirm") + .primary() + .label("Loading...") + .loading(true), + ), + ) + .into_any_element() + } DialogPhase::Error(err_msg) => { let pin_input = self.pin_input.clone(); @@ -260,6 +277,7 @@ impl Render for PinPromptContent { } } +/// Open a PIN prompt dialog and return the submitted PIN. pub fn open_pin_prompt( title: &str, description: &str, @@ -311,6 +329,7 @@ pub fn open_pin_prompt( }); } +/// Dialog content for confirming a destructive or irreversible action. pub struct ConfirmContent { phase: DialogPhase, title: SharedString, @@ -326,11 +345,13 @@ impl ConfirmContent { cx.notify(); } + /// Transition the dialog to a success state. pub fn set_success(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Success(msg); cx.notify(); } + /// Transition the dialog to an error state with the given message. pub fn set_error(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Error(msg); cx.notify(); @@ -464,6 +485,7 @@ impl Render for ConfirmContent { } } +/// Open a confirmation dialog and return whether the user accepted. pub fn open_confirm( title: &str, message: String, @@ -494,6 +516,7 @@ pub fn open_confirm( }); } +/// Dialog content for changing an existing FIDO PIN. pub struct ChangePinContent { phase: DialogPhase, current_pin: Entity, @@ -509,11 +532,13 @@ impl ChangePinContent { cx.notify(); } + /// Transition the dialog to a success state. pub fn set_success(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Success(msg); cx.notify(); } + /// Transition the dialog to an error state with the given message. pub fn set_error(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Error(msg); cx.notify(); @@ -759,6 +784,7 @@ impl Render for ChangePinContent { } } +/// Open a dialog to change the FIDO PIN. pub fn open_change_pin( window: &mut Window, cx: &mut App, @@ -811,6 +837,7 @@ pub fn open_change_pin( }); } +/// Dialog content for setting an initial FIDO PIN. pub struct SetPinContent { phase: DialogPhase, new_pin: Entity, @@ -825,11 +852,13 @@ impl SetPinContent { cx.notify(); } + /// Transition the dialog to a success state. pub fn set_success(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Success(msg); cx.notify(); } + /// Transition the dialog to an error state with the given message. pub fn set_error(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Error(msg); cx.notify(); @@ -1052,6 +1081,7 @@ impl Render for SetPinContent { } } +/// Open a dialog to set an initial FIDO PIN. pub fn open_setup_pin( window: &mut Window, cx: &mut App, @@ -1097,6 +1127,7 @@ pub fn open_setup_pin( .close_button(false) }); } +/// Dialog content for showing operation progress, success, or error. pub struct StatusContent { phase: DialogPhase, title: SharedString, @@ -1215,6 +1246,7 @@ impl Render for StatusContent { } } +/// Open a status dialog showing progress, success, or error state. pub fn open_status_dialog( title: &str, window: &mut Window, diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 374db43..42ab88e 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,3 +1,5 @@ +//! Reusable UI components built on top of gpui-component primitives. + pub mod button; pub mod card; pub mod dialog; diff --git a/src/ui/components/page_view.rs b/src/ui/components/page_view.rs index 5e29bfb..b7c76d1 100644 --- a/src/ui/components/page_view.rs +++ b/src/ui/components/page_view.rs @@ -1,6 +1,9 @@ +//! Contained page layout wrapper for screen content. + use gpui::*; use gpui_component::{StyledExt, Theme, v_flex}; +/// A contained page layout providing consistent padding and max-width for screen content. pub struct PageView; impl PageView { diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index 4628d4f..9432e28 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -1,3 +1,5 @@ +//! Application sidebar with navigation items and collapse support. + use crate::ui::app::Destination; use crate::ui::components::button::PFIconButton; use crate::ui::models::device::{DeviceMethod, DeviceRepo}; @@ -43,14 +45,17 @@ impl AppSidebar { } } + /// The current animated sidebar width in pixels. pub fn current_width(&self) -> Pixels { self.current_width } + /// Whether the sidebar is currently collapsed. pub fn collapsed(&self) -> bool { self.collapsed } + /// Whether the sidebar toggle button is being hovered. pub fn toggle_hovered(&self) -> bool { self.toggle_hovered } diff --git a/src/ui/components/tag.rs b/src/ui/components/tag.rs index d457fff..91ae4c7 100644 --- a/src/ui/components/tag.rs +++ b/src/ui/components/tag.rs @@ -1,5 +1,8 @@ +//! Simple tag/badge component for displaying status or labels. + use gpui::*; +/// A small badge showing a status or category label. #[derive(IntoElement)] pub struct Tag { label: SharedString, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ce99ffa..94d0a08 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -19,7 +19,7 @@ //! determines which screen is displayed. //! * **View-model registry** — [`ViewModelStore`](app::ViewModelStore) that //! lazily initializes each screen's view-model on first navigation. -//! * **Sidebar** — [`Entity`] that owns its own collapse state, width +//! * **Sidebar** — `Entity<[`AppSidebar`](crate::ui::components::sidebar::AppSidebar)>` that owns its own collapse state, width //! animation, and toggle hover state. The toggle button is rendered by //! [`ApplicationRoot`](app::ApplicationRoot) as the last child of `main-area` //! so it paints on top of the content column. @@ -127,14 +127,15 @@ //! //! ## Navigation & Data Flow //! -//! 1. [`AppSidebar`] emits [`SidebarEvent::Navigate`] when a nav item is clicked. -//! [`ApplicationRoot`] receives it via `cx.subscribe` and sets `active_destination`. +//! 1. [`AppSidebar`](crate::ui::components::sidebar::AppSidebar) emits +//! [`SidebarEvent::Navigate`](crate::ui::components::sidebar::SidebarEvent::Navigate) when a nav item is clicked. +//! [`ApplicationRoot`](crate::ui::app::ApplicationRoot) receives it via `cx.subscribe` and sets `active_destination`. //! 2. `ApplicationRoot::render` reads `active_destination` to decide which screen //! to display. Each screen's view-model is lazily created via `get_or_insert_with` //! on `ViewModelStore`. Passkeys survives navigation (only invalidated on device change). -//! 3. [`DeviceRepo::refresh()`] (called at startup and on sidebar refresh) performs the +//! 3. [`DeviceRepo::refresh()`](crate::ui::models::device::DeviceRepo::refresh) (called at startup and on sidebar refresh) performs the //! full HAL poll cycle — reads device details, FIDO info, LED/management config — and -//! emits [`DeviceEvent::Updated`]. All subscribers re-read from `DeviceRepo`. +//! emits [`DeviceEvent::Updated`](crate::ui::models::device::DeviceEvent::Updated). All subscribers re-read from `DeviceRepo`. //! 4. For writes, ViewModels call `DeviceRepo::*_blocking()` static methods from background //! tasks, then push fresh state via `repo.apply_fresh_state()`, which emits the event. //! 5. Screen view-models read `DeviceRepo` in their `Render` or event handlers diff --git a/src/ui/models/device.rs b/src/ui/models/device.rs index 0999dba..9483ea5 100644 --- a/src/ui/models/device.rs +++ b/src/ui/models/device.rs @@ -14,6 +14,7 @@ //! - **`apply_fresh_state()`** lets ViewModels push post-write HAL results //! back into the repo so subscribers get the event. +use crate::hal::firmwares::AnyFirmware; use crate::hal::io; use crate::hal::types; use gpui::*; @@ -23,12 +24,15 @@ pub use crate::hal::rescue::constants::{ USB_CAP_U2F, }; pub use types::{ - AppConfigInput, DeviceMethod, FidoDeviceInfo, FirmwareType, FullDeviceStatus, StoredCredential, + AppConfigInput, DeviceMethod, FidoDeviceInfo, FirmwareType, FullDeviceStatus, LedStatusConfig, + StoredCredential, }; // ── Events ────────────────────────────────────────────────────────────────── +/// Events emitted by [`DeviceRepo`] to notify subscribers of state changes. pub enum DeviceEvent { + /// Device details were refreshed. Updated, } @@ -36,6 +40,7 @@ impl EventEmitter for DeviceRepo {} // ── Snapshot returned by post-write state refresh ─────────────────────────── +/// Snapshot of device state produced by a blocking HAL read. #[derive(Clone)] pub struct FreshDeviceState { pub status: types::FullDeviceStatus, @@ -56,6 +61,7 @@ pub struct DeviceRepo { } impl DeviceRepo { + /// Create a new device repo in the disconnected state. pub fn new() -> Self { Self { status: None, @@ -70,18 +76,19 @@ impl DeviceRepo { // ── HAL static methods (blocking — call from background executor) ────── - pub fn firmware_supports_legacy_fido_config(version: &str) -> bool { - crate::hal::fido::firmware_supports_legacy_fido_hardware_config(version) + pub fn firmware_supports_legacy_fido_config( + fw_type: &types::FirmwareType, + version: &str, + ) -> bool { + AnyFirmware::new(fw_type.clone(), version).supports_legacy_fido_hardware_config() } pub fn read_device_state_blocking() -> Result { let status = io::read_device_details()?; - let (led_status, management_apps) = if status.firmware_type == types::FirmwareType::RSKey - && status.method == types::DeviceMethod::Rescue - { + let (led_status, management_apps) = if status.firmware_type == types::FirmwareType::RSKey { ( - io::read_led_config().ok(), - io::read_management_config().ok(), + io::read_led_config(status.method.clone()).ok(), + io::read_management_config(status.method.clone()).ok(), ) } else { (None, None) @@ -101,19 +108,20 @@ impl DeviceRepo { io::write_config(config, method, pin) } - pub fn write_led_status_blocking( - status_idx: u8, - color: u8, - brightness: u8, - steady: bool, + pub fn write_led_config_blocking( + method: DeviceMethod, + config: LedStatusConfig, + pin: Option, ) -> Result { - io::write_led_status(status_idx, color, brightness, steady) + io::write_led_config(method, config, pin) } pub fn write_management_config_blocking( + method: DeviceMethod, enabled_mask: u16, + pin: Option, ) -> Result { - io::write_management_config(enabled_mask) + io::write_management_config(method, enabled_mask, pin) } pub fn get_fido_info_blocking() -> Result { @@ -166,7 +174,7 @@ impl DeviceRepo { } pub fn check_hid_available_blocking() -> bool { - crate::hal::fido::hid::HidTransport::open().is_ok() + crate::hal::transport::fido::HidTransport::open().is_ok() } // ── State mutation (called from ViewModel after background work) ─────── @@ -198,6 +206,7 @@ impl DeviceRepo { // ── Polling cycle ────────────────────────────────────────────────────── + /// Initiate a device-details refresh (async, emits [`DeviceEvent::Updated`] on completion). pub fn refresh(&mut self, cx: &mut Context) { if self.loading { return; @@ -223,11 +232,9 @@ impl DeviceRepo { } } - if status.firmware_type == types::FirmwareType::RSKey - && status.method == types::DeviceMethod::Rescue - { - self.led_status = io::read_led_config().ok(); - self.management_apps = io::read_management_config().ok(); + if status.firmware_type == types::FirmwareType::RSKey { + self.led_status = io::read_led_config(status.method.clone()).ok(); + self.management_apps = io::read_management_config(status.method.clone()).ok(); } else { self.led_status = None; self.management_apps = None; @@ -246,15 +253,18 @@ impl DeviceRepo { // ── State lifecycle helpers ──────────────────────────────────────────── + /// Mark the repo as loading. pub fn begin_load(&mut self) { self.loading = true; self.error = None; } + /// Mark the repo as finished loading. pub fn end_load(&mut self) { self.loading = false; } + /// Set an error state on the repo. pub fn set_error(&mut self, error: String) { self.status = None; self.fido_info = None; diff --git a/src/ui/models/mod.rs b/src/ui/models/mod.rs index 5458924..d309f97 100644 --- a/src/ui/models/mod.rs +++ b/src/ui/models/mod.rs @@ -1 +1,3 @@ +//! View-model and state types bridging the UI layer with the HAL. + pub mod device; diff --git a/src/ui/screens/about/mod.rs b/src/ui/screens/about/mod.rs index 5b085b2..576fd9d 100644 --- a/src/ui/screens/about/mod.rs +++ b/src/ui/screens/about/mod.rs @@ -1,3 +1,5 @@ +//! About screen — application version, licenses, firmware compatibility. + pub mod view; pub mod view_model; pub use view_model::AboutViewModel; diff --git a/src/ui/screens/about/view_model.rs b/src/ui/screens/about/view_model.rs index 8e6c9c6..a59671d 100644 --- a/src/ui/screens/about/view_model.rs +++ b/src/ui/screens/about/view_model.rs @@ -1,6 +1,9 @@ +//! View model for the about screen — version info and firmware compatibility. + use crate::ui::app::AppModels; use gpui::*; +/// Application metadata and firmware compatibility information. pub struct AboutViewModel; impl AboutViewModel { diff --git a/src/ui/screens/config/mod.rs b/src/ui/screens/config/mod.rs index fbb1237..0537070 100644 --- a/src/ui/screens/config/mod.rs +++ b/src/ui/screens/config/mod.rs @@ -1,3 +1,5 @@ +//! Configuration screen — USB identifiers, LED settings, touch timeout, curves. + pub mod view; pub mod view_model; pub use view_model::ConfigViewModel; diff --git a/src/ui/screens/config/view.rs b/src/ui/screens/config/view.rs index cdf91e2..9a7a896 100644 --- a/src/ui/screens/config/view.rs +++ b/src/ui/screens/config/view.rs @@ -578,39 +578,44 @@ impl Render for ConfigViewModel { let device = self.device.read(cx); let status = device.status.clone(); let is_fido = status.as_ref().map(|s| s.method.clone()) == Some(DeviceMethod::Fido); + let is_rskey = status.as_ref().map(|s| &s.firmware_type) == Some(&FirmwareType::RSKey); + let supports_legacy_fido_config = status .as_ref() .map(ConfigViewModel::status_supports_legacy_fido_config) .unwrap_or(false); - let hardware_config_disabled = is_fido && !supports_legacy_fido_config; + + let hardware_config_disabled = is_fido && !supports_legacy_fido_config && !is_rskey; + + // RS-Key supports full config read/write over FIDO via CONFIG_READ/CONFIG_WRITE. + // Other firmwares (pico-fido) don't: product name, LED driver, curves, etc. + let fido_no_rskey = is_fido && !is_rskey; let led_card = self - .render_led_card(cx, is_fido, hardware_config_disabled) + .render_led_card(cx, fido_no_rskey, hardware_config_disabled) .into_any_element(); let options_card = self - .render_options_card(cx, is_fido, hardware_config_disabled) + .render_options_card(cx, fido_no_rskey, hardware_config_disabled) .into_any_element(); let identity_card = self - .render_identity_card(cx.theme(), is_fido, hardware_config_disabled) + .render_identity_card(cx.theme(), fido_no_rskey, hardware_config_disabled) .into_any_element(); let touch_card = self - .render_touch_card(cx.theme(), is_fido) + .render_touch_card(cx.theme(), fido_no_rskey) .into_any_element(); let is_wide = window.bounds().size.width > px(1100.0); let columns = if is_wide { 2 } else { 1 }; - let is_rskey = status.as_ref().map(|s| &s.firmware_type) == Some(&FirmwareType::RSKey); - let mut grid_children = vec![identity_card, led_card, touch_card, options_card]; if is_rskey { - let rskey_led = self.render_rskey_led_card(cx, is_fido).into_any_element(); - let rskey_apps = self.render_rskey_apps_card(cx, is_fido).into_any_element(); - let rskey_usb_itf = self - .render_rskey_usb_itf_card(cx, is_fido) - .into_any_element(); + // RS-Key cards always enabled in FIDO mode — they work via + // CONFIG_WRITE with PIN token. + let rskey_led = self.render_rskey_led_card(cx, false).into_any_element(); + let rskey_apps = self.render_rskey_apps_card(cx, false).into_any_element(); + let rskey_usb_itf = self.render_rskey_usb_itf_card(cx, false).into_any_element(); grid_children.push(rskey_led); grid_children.push(rskey_apps); grid_children.push(rskey_usb_itf); diff --git a/src/ui/screens/config/view_model.rs b/src/ui/screens/config/view_model.rs index d649b5b..32293fc 100644 --- a/src/ui/screens/config/view_model.rs +++ b/src/ui/screens/config/view_model.rs @@ -1,14 +1,17 @@ +//! View model for the configuration screen — form state and save logic. + use crate::ui::app::AppModels; use crate::ui::components::dialog::PinPromptContent; use crate::ui::components::{dialog, dialog::StatusContent}; use crate::ui::models::device::{ - AppConfigInput, DeviceEvent, DeviceMethod, DeviceRepo, FullDeviceStatus, + AppConfigInput, DeviceEvent, DeviceMethod, DeviceRepo, FullDeviceStatus, LedStatusConfig, }; use gpui::*; use gpui_component::input::InputState; use gpui_component::select::{SelectItem, SelectState}; use gpui_component::slider::SliderState; +/// Known USB vendor/product identity presets for various security keys. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UsbIdentityPreset { Custom, @@ -120,6 +123,7 @@ impl UsbIdentityPreset { } } +/// Supported LED driver types for the device. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LedDriverType { PicoGpio = 1, @@ -193,6 +197,7 @@ pub(super) enum StatusDialogHandle { Status(WeakEntity), } +/// Form state, input bindings, and save logic for the configuration screen. pub struct ConfigViewModel { pub(super) device: Entity, pub(super) vendor_select: Entity>>, @@ -446,11 +451,33 @@ impl ConfigViewModel { return; } + let dialog = dialog_handle; + + // Tell the user to look at their key! + cx.update(|cx| { + match &dialog { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_loading_msg("Applying configuration... Please touch your device if it flashes.", cx); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_loading("Applying configuration... Please touch your device if it flashes.", cx); + }); + } + } + }).ok(); + let result = cx .background_executor() - .spawn(async move { DeviceRepo::write_config_blocking(changes, method_clone, pin) }) + .spawn(async move { + DeviceRepo::write_config_blocking(changes, method_clone, pin) + }) .await; + let dialog_handle = dialog; + let fresh_state = if result.is_ok() { cx.background_executor() .spawn(async move { DeviceRepo::read_device_state_blocking().ok() }) @@ -516,6 +543,8 @@ impl ConfigViewModel { if method == DeviceMethod::Fido && err_msg.contains("0x3E") { err_msg = "The device firmware does not support being configured in fido only communication mode. \nHave a look at the troubleshooting guide to fix this".to_string(); + } else if method == DeviceMethod::Fido && err_msg.contains("0x27") { + err_msg = "Configuration denied (Status: 0x27). This usually means the operation timed out waiting for you to touch the device's button, or the PIN token was rejected.".to_string(); } match &dialog_handle { @@ -677,10 +706,12 @@ impl ConfigViewModel { raw_curves_mask, led_order, enabled_usb_itf: final_enabled_usb_itf, + led_num: None, }; if method == DeviceMethod::Fido { - if Self::status_supports_legacy_fido_config(status) { + let is_rskey = status.firmware_type == crate::ui::models::device::FirmwareType::RSKey; + if Self::status_supports_legacy_fido_config(status) || is_rskey { self.open_pin_dialog(changes, window, cx); } else { let handle = @@ -707,7 +738,10 @@ impl ConfigViewModel { pub(super) fn status_supports_legacy_fido_config(status: &FullDeviceStatus) -> bool { status.method == DeviceMethod::Fido - && DeviceRepo::firmware_supports_legacy_fido_config(&status.info.firmware_version) + && DeviceRepo::firmware_supports_legacy_fido_config( + &status.firmware_type, + &status.info.firmware_version, + ) } #[allow(dead_code)] @@ -798,27 +832,194 @@ impl ConfigViewModel { } pub(super) fn apply_rskey_led_settings(&mut self, window: &mut Window, cx: &mut Context) { - let steady = self.led_status_steady; - let colors = self.led_status_colors; - let brightnesses = self.led_status_brightness; + let config = LedStatusConfig { + steady: self.led_status_steady, + statuses: [ + (self.led_status_colors[0], self.led_status_brightness[0]), + (self.led_status_colors[1], self.led_status_brightness[1]), + (self.led_status_colors[2], self.led_status_brightness[2]), + (self.led_status_colors[3], self.led_status_brightness[3]), + ], + }; + let method = self + .device + .read(cx) + .status + .as_ref() + .map(|s| s.method.clone()); + + if method == Some(DeviceMethod::Fido) { + let view_handle = cx.entity().downgrade(); + dialog::open_pin_prompt( + "Authentication Required", + "Enter your device PIN to update LED configuration.", + None, + "Confirm", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.do_write_led_config( + config.clone(), + DeviceMethod::Fido, + Some(pin), + StatusDialogHandle::Pin(dialog_handle), + cx, + ); + }); + }, + ); + } else { + let handle = dialog::open_status_dialog("Applying LED Configuration...", window, cx); + self.do_write_led_config( + config, + DeviceMethod::Rescue, + None, + StatusDialogHandle::Status(handle), + cx, + ); + } + } + + fn do_write_led_config( + &mut self, + config: LedStatusConfig, + method: DeviceMethod, + pin: Option, + dialog_handle: StatusDialogHandle, + cx: &mut Context, + ) { self.loading = true; - let handle = dialog::open_status_dialog("Applying LED Configuration...", window, cx); + cx.notify(); + + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { DeviceRepo::write_led_config_blocking(method, config, pin) }) + .await; + + let fresh_state = if result.is_ok() { + cx.background_executor() + .spawn(async move { DeviceRepo::read_device_state_blocking().ok() }) + .await + } else { + None + }; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + if let Some(fs) = fresh_state { + this.device.update(cx, |repo, repo_cx| { + repo.apply_fresh_state(fs, repo_cx); + }); + } + match &dialog_handle { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_success( + "LED configuration applied successfully.".to_string(), + cx, + ); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_success( + "LED configuration applied successfully.".to_string(), + cx, + ); + }); + } + } + } + Err(e) => match &dialog_handle { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_error(format!("Failed to apply LED config: {}", e), cx); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_error(format!("Failed to apply LED config: {}", e), cx); + }); + } + }, + } + cx.notify(); + }); + })); + } + + pub(super) fn apply_rskey_apps_settings( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let mask = self.usb_apps_enabled; + + let method = self + .device + .read(cx) + .status + .as_ref() + .map(|s| s.method.clone()); + + if method == Some(DeviceMethod::Fido) { + let view_handle = cx.entity().downgrade(); + dialog::open_pin_prompt( + "Authentication Required", + "Enter your device PIN to update USB application configuration.", + None, + "Confirm", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.do_write_management_config( + mask, + DeviceMethod::Fido, + Some(pin), + StatusDialogHandle::Pin(dialog_handle), + cx, + ); + }); + }, + ); + } else { + let handle = dialog::open_status_dialog("Applying USB Applications...", window, cx); + self.do_write_management_config( + mask, + DeviceMethod::Rescue, + None, + StatusDialogHandle::Status(handle), + cx, + ); + } + } + + fn do_write_management_config( + &mut self, + mask: u16, + method: DeviceMethod, + pin: Option, + dialog_handle: StatusDialogHandle, + cx: &mut Context, + ) { + self.loading = true; + cx.notify(); + let entity = cx.entity().downgrade(); self._task = Some(cx.spawn(async move |_, cx| { let result = cx .background_executor() .spawn(async move { - for i in 0..4 { - DeviceRepo::write_led_status_blocking( - i as u8, - colors[i], - brightnesses[i], - steady, - )?; - } - Ok::<_, crate::error::PFError>(()) + DeviceRepo::write_management_config_blocking(method, mask, pin) }) .await; @@ -839,70 +1040,38 @@ impl ConfigViewModel { repo.apply_fresh_state(fs, repo_cx); }); } - let _ = handle.update(cx, |d, cx| { - d.set_success( - "LED configuration applied successfully.".to_string(), - cx, - ); - }); - } - Err(e) => { - let _ = handle.update(cx, |d, cx| { - d.set_error(format!("Failed to apply LED config: {}", e), cx); - }); - } - } - cx.notify(); - }); - })); - } - - pub(super) fn apply_rskey_apps_settings( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - let mask = self.usb_apps_enabled; - - self.loading = true; - let handle = dialog::open_status_dialog("Applying USB Applications...", window, cx); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { DeviceRepo::write_management_config_blocking(mask) }) - .await; - - let fresh_state = if result.is_ok() { - cx.background_executor() - .spawn(async move { DeviceRepo::read_device_state_blocking().ok() }) - .await - } else { - None - }; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(_) => { - if let Some(fs) = fresh_state { - this.device.update(cx, |repo, repo_cx| { - repo.apply_fresh_state(fs, repo_cx); - }); + match &dialog_handle { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_success( + "USB applications updated successfully. Please re-plug the device.".to_string(), + cx, + ); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_success( + "USB applications updated successfully. Please re-plug the device.".to_string(), + cx, + ); + }); + } } - let _ = handle.update(cx, |d, cx| { - d.set_success( - "USB applications updated successfully. Please re-plug the device." - .to_string(), - cx, - ); - }); } Err(e) => { - let _ = handle.update(cx, |d, cx| { - d.set_error(format!("Failed to apply USB applications: {}", e), cx); - }); + match &dialog_handle { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_error(format!("Failed to apply USB applications: {}", e), cx); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_error(format!("Failed to apply USB applications: {}", e), cx); + }); + } + } } } cx.notify(); diff --git a/src/ui/screens/home/mod.rs b/src/ui/screens/home/mod.rs index ba441e2..beaca88 100644 --- a/src/ui/screens/home/mod.rs +++ b/src/ui/screens/home/mod.rs @@ -1,3 +1,5 @@ +//! Home screen — device overview, connection status, quick actions. + pub mod view; pub mod view_model; pub use view_model::HomeViewModel; diff --git a/src/ui/screens/home/view.rs b/src/ui/screens/home/view.rs index 49a48a6..cab27e8 100644 --- a/src/ui/screens/home/view.rs +++ b/src/ui/screens/home/view.rs @@ -1,5 +1,5 @@ use crate::ui::components::{card::Card, page_view::PageView, tag::Tag}; -use crate::ui::models::device::{DeviceMethod, FidoDeviceInfo, FullDeviceStatus}; +use crate::ui::models::device::{DeviceMethod, FidoDeviceInfo, FirmwareType, FullDeviceStatus}; use crate::ui::screens::home::view_model::HomeViewModel; use gpui::prelude::FluentBuilder; use gpui::*; @@ -245,10 +245,12 @@ impl HomeViewModel { fn render_led_config(status: &FullDeviceStatus, theme: &Theme) -> impl IntoElement { let config = &status.config; + let has_fido_config = + status.firmware_type == FirmwareType::RSKey || status.method != DeviceMethod::Fido; Card::new() .title("LED Configuration") .icon(Icon::default().path("icons/microchip.svg")) - .child(if status.method == DeviceMethod::Fido { + .child(if !has_fido_config { v_flex() .items_center() .justify_center() diff --git a/src/ui/screens/home/view_model.rs b/src/ui/screens/home/view_model.rs index 2cb18f5..9d8c6af 100644 --- a/src/ui/screens/home/view_model.rs +++ b/src/ui/screens/home/view_model.rs @@ -1,7 +1,10 @@ +//! View model for the home screen — tracks device connection state and polling. + use crate::ui::app::AppModels; use crate::ui::models::device::{DeviceEvent, DeviceRepo}; use gpui::*; +/// Application state and device-detection polling for the home screen. pub struct HomeViewModel { pub device: Entity, } diff --git a/src/ui/screens/passkeys/mod.rs b/src/ui/screens/passkeys/mod.rs index e4b8dee..2324b4d 100644 --- a/src/ui/screens/passkeys/mod.rs +++ b/src/ui/screens/passkeys/mod.rs @@ -1,3 +1,5 @@ +//! Passkeys screen — credential listing, deletion, and PIN management. + pub mod view; pub mod view_model; pub use view_model::{PasskeysEvent, PasskeysViewModel}; diff --git a/src/ui/screens/passkeys/view_model.rs b/src/ui/screens/passkeys/view_model.rs index 45ecc5b..eb527aa 100644 --- a/src/ui/screens/passkeys/view_model.rs +++ b/src/ui/screens/passkeys/view_model.rs @@ -1,3 +1,5 @@ +//! View model for the passkeys screen — credential listing and management. + use crate::ui::app::AppModels; use crate::ui::components::dialog; use crate::ui::components::dialog::{ @@ -8,6 +10,7 @@ use gpui::*; use gpui_component::button::ButtonVariants; use gpui_component::{ActiveTheme, StyledExt, WindowExt}; +/// Credential state, PIN management, and FIDO storage operations. pub struct PasskeysViewModel { pub(super) device: Entity, pub(super) credentials: Vec, @@ -20,6 +23,7 @@ pub struct PasskeysViewModel { pub(super) _task: Option>, } +/// Events emitted by [`PasskeysViewModel`] to notify the parent of UI-level actions. pub enum PasskeysEvent { Notification(String), } diff --git a/src/ui/screens/security/mod.rs b/src/ui/screens/security/mod.rs index 7adbba6..be84bc8 100644 --- a/src/ui/screens/security/mod.rs +++ b/src/ui/screens/security/mod.rs @@ -1,3 +1,5 @@ +//! Security screen — secure boot, enterprise attestation, device reset. + pub mod view; pub mod view_model; pub use view_model::SecurityViewModel; diff --git a/src/ui/screens/security/view_model.rs b/src/ui/screens/security/view_model.rs index 6c243c6..da06230 100644 --- a/src/ui/screens/security/view_model.rs +++ b/src/ui/screens/security/view_model.rs @@ -1,6 +1,9 @@ +//! View model for the security screen — secure boot and attestation state. + use crate::ui::app::AppModels; use gpui::*; +/// Security-related state — stub for secure boot, attestation, and reset operations. pub struct SecurityViewModel; impl SecurityViewModel {