From 790966d4cb1bfb7bd8ea5a55a7dd7cb4ea48cd53 Mon Sep 17 00:00:00 2001 From: Fabrice Bellamy Date: Mon, 2 Mar 2026 12:31:13 +0100 Subject: [PATCH] remove dependency on ctap-hid-fido2 and use our own implementation of fido commands to avoid picoforge freeze with som bad firmwares --- Cargo.lock | 216 +------ Cargo.toml | 3 +- src/device/fido/constants.rs | 46 +- src/device/fido/hid.rs | 1119 +++++++++++++++++++++++++++++++++- src/device/fido/mod.rs | 222 +++---- 5 files changed, 1284 insertions(+), 322 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f357b4b..93699ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,45 +196,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "asn1-rs" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -884,33 +845,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "cipher" version = "0.4.4" @@ -1001,7 +935,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -1318,29 +1252,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctap-hid-fido2" -version = "3.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab54a72e056ad648b432faab0782512a989074a0e15d7c01e24825b22b0d033" -dependencies = [ - "aes", - "anyhow", - "base64", - "byteorder", - "cbc", - "ciborium", - "hex", - "hidapi", - "num", - "pad", - "rand 0.9.2", - "ring", - "strum 0.27.2", - "strum_macros 0.27.2", - "x509-parser", -] - [[package]] name = "ctor" version = "0.4.3" @@ -1357,12 +1268,6 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "data-url" version = "0.3.2" @@ -1375,29 +1280,6 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "derive_more" version = "0.99.20" @@ -3854,12 +3736,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - [[package]] name = "num-derive" version = "0.4.2" @@ -4058,15 +3934,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -4150,15 +4017,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "pad" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ad9b889f1b12e0b9ee24db044b5129150d5eada288edc800f789928dc8c0e3" -dependencies = [ - "unicode-width 0.1.14", -] - [[package]] name = "parking" version = "2.2.1" @@ -4308,10 +4166,11 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" name = "picoforge" version = "0.4.1" dependencies = [ + "aes", "anyhow", "bitflags 2.11.0", "byteorder", - "ctap-hid-fido2", + "cbc", "directories", "gpui", "gpui-component", @@ -4451,12 +4310,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -5103,15 +4956,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "rustix" version = "0.38.44" @@ -6127,37 +5971,6 @@ dependencies = [ "zune-jpeg 0.4.21", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" @@ -6595,12 +6408,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -7743,23 +7550,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - [[package]] name = "xattr" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index ffe47d2..f99ced7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,12 +21,13 @@ hex = "0.4" # For parsing VID/PID strings byteorder = "1.5" # Required for writing Big-Endian numbers (firmware requirement) thiserror = "2" # Makes custom error handling much easier anyhow = "1" # For easy error propagation -ctap-hid-fido2 = "3.5" # For fido2 interface operations hidapi = "2.6" # For fido2 interface operations but non-standard commands serde_cbor_2 = "0.13" rand = "0.10" bitflags = "2.11" ring = "0.17" # For signing fido2 messages with pin token +aes = "0.8" +cbc = "0.1" # For Application UI: gpui = { version = "0.2.2", features = [] } diff --git a/src/device/fido/constants.rs b/src/device/fido/constants.rs index 2b433e1..d2797f3 100644 --- a/src/device/fido/constants.rs +++ b/src/device/fido/constants.rs @@ -54,7 +54,7 @@ pub enum ClientPinSubCommand { GetPinToken = 0x05, GetPinUvAuthTokenUsingUvWithPermissions = 0x06, GetUvRetries = 0x07, - GetPinUvAuthTokenUsingPinWithPermissions = 0x08, + GetPinUvAuthTokenUsingPinWithPermissions = 0x09, // TODO: per fido spec, this should be 0x08? Needs to confirm and fix the firmware if true. } #[repr(u8)] @@ -97,6 +97,16 @@ pub enum ClientPinParam { PermissionsRpId = 0x0A, } +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientPinResponseParam { + KeyAgreement = 0x01, + PinToken = 0x02, + PinRetries = 0x03, + NextMsg = 0x04, + UvRetries = 0x05, +} + #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigParam { @@ -133,6 +143,39 @@ pub enum VendorSubParam { VendorParamText = 0x04, } +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialMgmtSubCommand { + GetCredsMetadata = 0x01, + EnumerateRpsBegin = 0x02, + EnumerateRpsGetNextRp = 0x03, + EnumerateCredentialsBegin = 0x04, + EnumerateCredentialsGetNextCredential = 0x05, + DeleteCredential = 0x06, + UpdateUserInformation = 0x07, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialMgmtParam { + SubCommand = 0x01, + SubCommandParams = 0x02, + PinUvAuthProtocol = 0x03, + PinUvAuthParam = 0x04, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialMgmtResponseParam { + Rp = 0x03, + RpIdHash = 0x04, + TotalRps = 0x05, + User = 0x06, + CredentialId = 0x07, + PublicKey = 0x08, + TotalCredentials = 0x09, +} + #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigSubCommandParam { @@ -276,6 +319,7 @@ pub enum MemoryResponseKey { } bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PinUvAuthTokenPermissions: u8 { const MAKE_CREDENTIAL = 0x01; const GET_ASSERTION = 0x02; diff --git a/src/device/fido/hid.rs b/src/device/fido/hid.rs index 5b11251..9ad7ed6 100644 --- a/src/device/fido/hid.rs +++ b/src/device/fido/hid.rs @@ -1,5 +1,8 @@ +use aes::cipher::generic_array::GenericArray; +use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, block_padding::NoPadding}; use rand::RngExt; -use serde_cbor_2::{Value, to_vec}; +use ring::{agreement, digest, hmac}; +use serde_cbor_2::{Value, from_slice, to_vec}; use std::collections::BTreeMap; use std::time::Duration; @@ -20,6 +23,7 @@ const HID_READ_TIMEOUT_MS: i32 = 10; const HID_INIT_READ_TIMEOUT_MS: i32 = 100; const HID_RESP_READ_TIMEOUT_MS: i32 = 2000; const HID_CONT_READ_TIMEOUT_MS: i32 = 500; +const HID_TOTAL_TIMEOUT_MS: i32 = 5000; pub struct HidTransport { device: hidapi::HidDevice, @@ -29,6 +33,24 @@ pub struct HidTransport { pub product_name: String, } +#[derive(Debug, Clone)] +pub struct EnumerateRpResponse { + pub rp: Value, + pub rp_id_hash: Vec, + #[allow(dead_code)] + pub total_rps: Option, +} + +#[derive(Debug, Clone)] +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 { pub fn open() -> Result { log::info!("Attempting to open HID transport for FIDO device..."); @@ -221,8 +243,18 @@ impl HidTransport { 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(HID_TOTAL_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) @@ -402,7 +434,7 @@ impl HidTransport { let mut payload = vec![CtapCommand::Config as u8]; payload.extend(config_payload_cbor); - // Send via HID + log::debug!("Sending config command..."); self.send_cbor(CTAPHID_CBOR, &payload).map_err(|e| { log::error!("Failed to send FIDO config: {}", e); PFError::Device(format!("FIDO config failed: {}", e)) @@ -470,7 +502,7 @@ impl HidTransport { let mut payload = vec![CtapCommand::Config as u8]; payload.extend(config_payload_cbor); - // Send via HID + log::debug!("Sending minimum PIN length config command..."); match self.send_cbor(CTAPHID_CBOR, &payload) { Ok(_) => { log::info!( @@ -495,6 +527,538 @@ impl HidTransport { } } + pub fn get_key_agreement(&self) -> Result { + let mut map = BTreeMap::new(); + map.insert( + Value::Integer(ClientPinParam::PinUvAuthProtocol as i128), + Value::Integer(1), + ); + map.insert( + Value::Integer(ClientPinParam::SubCommand as i128), + Value::Integer(ClientPinSubCommand::GetKeyAgreement as i128), + ); + + let mut payload = vec![CtapCommand::ClientPin as u8]; + payload.extend(to_vec(&Value::Map(map)).map_err(|e| PFError::Io(e.to_string()))?); + + log::debug!("Sending GetKeyAgreement command..."); + let resp = self.send_cbor(CTAPHID_CBOR, &payload)?; + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + + if let Value::Map(m) = val { + log::debug!("GetKeyAgreement response: {:?}", m); + m.get(&Value::Integer( + ClientPinResponseParam::KeyAgreement as i128, + )) + .cloned() + .ok_or_else(|| PFError::Device("KeyAgreement not found in response".into())) + } else { + Err(PFError::Device( + "Unexpected response for GetKeyAgreement".into(), + )) + } + } + + pub fn get_pin_token(&self, pin: &str) -> Result, PFError> { + log::info!("Starting custom get_pin_token (Subcommand 0x05)..."); + + // 1. Get Authenticator Key Agreement + let auth_key_agreement = self.get_key_agreement()?; + + // 2. Generate Platform Key Pair (P-256) + let rng = ring::rand::SystemRandom::new(); + let platform_private_key = + agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng) + .map_err(|_| PFError::Device("Failed to generate platform ephemeral key".into()))?; + let platform_public_key_bytes = platform_private_key + .compute_public_key() + .map_err(|_| PFError::Device("Failed to compute platform public key".into()))?; + + // 3. Extract Authenticator Public Key (X and Y coordinates) + let (auth_x, auth_y) = if let Value::Map(m) = &auth_key_agreement { + let x = match m.get(&Value::Integer(-2)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement X coordinate".into())), + }; + let y = match m.get(&Value::Integer(-3)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement Y coordinate".into())), + }; + (x, y) + } else { + return Err(PFError::Device("Invalid KeyAgreement format".into())); + }; + + let mut auth_pub_key_bytes = vec![0x04]; + auth_pub_key_bytes.extend(auth_x); + auth_pub_key_bytes.extend(auth_y); + + let auth_unparsed_pub_key = + agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, auth_pub_key_bytes); + + // 4. Perform ECDH to get Shared Secret + let shared_secret = + agreement::agree_ephemeral(platform_private_key, &auth_unparsed_pub_key, |material| { + let mut hasher = digest::Context::new(&digest::SHA256); + hasher.update(material); + Ok(hasher.finish()) as Result + }) + .map_err(|_| PFError::Device("ECDH shared secret computation failed".into()))? + .map_err(|_| PFError::Device("Inner ECDH shared secret computation failed".into()))?; + + // 5. Encrypt PIN Hash + let pin_hash = digest::digest(&digest::SHA256, pin.as_bytes()); + let pin_hash_16 = &pin_hash.as_ref()[0..16]; + + let iv = [0u8; 16]; + let mut block = *GenericArray::from_slice(pin_hash_16); + + let shared_secret_bytes = shared_secret.as_ref(); + let mut encryptor = cbc::Encryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ); + encryptor.encrypt_block_mut(&mut block); + let pin_hash_enc = block.to_vec(); + + // 6. Send getPinToken command (Subcommand 0x05) + + // 7. Send getPinToken command (Subcommand 0x05) + let cose_key_bytes = self.encode_cose_key( + &platform_public_key_bytes.as_ref()[1..33], + &platform_public_key_bytes.as_ref()[33..65], + ); + + let payload_cbor = self.encode_client_pin_params( + ClientPinSubCommand::GetPinToken, + &cose_key_bytes, + &pin_hash_enc, + None, + None, + ); + + let mut payload = vec![CtapCommand::ClientPin as u8]; + payload.extend(payload_cbor); + + log::debug!("Sending getPinToken command..."); + let resp = self.send_cbor(CTAPHID_CBOR, &payload)?; + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + + if let Value::Map(m) = val { + log::debug!("getPinToken response: {:?}", m); + match m.get(&Value::Integer(ClientPinResponseParam::PinToken as i128)) { + Some(Value::Bytes(token_enc)) => { + // Decrypt the PIN token using shared secret (AES-256-CBC, IV=0) + let mut token_buf = token_enc.clone(); + let decrypted = cbc::Decryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ) + .decrypt_padded_mut::(&mut token_buf) + .map_err(|_| PFError::Device("Failed to decrypt PIN token".into()))?; + log::info!("Successfully obtained and decrypted PIN token (Subcommand 0x05)."); + Ok(decrypted.to_vec()) + } + _ => Err(PFError::Device("pinToken not found in response".into())), + } + } else { + Err(PFError::Device("Unexpected response format".into())) + } + } + + pub fn get_pin_token_with_permission( + &self, + pin: &str, + permissions: PinUvAuthTokenPermissions, + rp_id: Option, + ) -> Result, PFError> { + log::info!( + "Starting custom get_pin_token_with_permission (Subcommand 0x09, permissions: {:?})...", + permissions + ); + + // 1. Get Authenticator Key Agreement + let auth_key_agreement = self.get_key_agreement()?; + + // 2. Generate Platform Key Pair (P-256) + let rng = ring::rand::SystemRandom::new(); + let platform_private_key = + agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng) + .map_err(|_| PFError::Device("Failed to generate platform ephemeral key".into()))?; + let platform_public_key_bytes = platform_private_key + .compute_public_key() + .map_err(|_| PFError::Device("Failed to compute platform public key".into()))?; + + // 3. Extract Authenticator Public Key (X and Y coordinates) + let (auth_x, auth_y) = if let Value::Map(m) = &auth_key_agreement { + let x = match m.get(&Value::Integer(-2)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement X coordinate".into())), + }; + let y = match m.get(&Value::Integer(-3)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement Y coordinate".into())), + }; + (x, y) + } else { + return Err(PFError::Device("Invalid KeyAgreement format".into())); + }; + + let mut auth_pub_key_bytes = vec![0x04]; + auth_pub_key_bytes.extend(auth_x); + auth_pub_key_bytes.extend(auth_y); + + let auth_unparsed_pub_key = + agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, auth_pub_key_bytes); + + // 4. Perform ECDH to get Shared Secret + let shared_secret = + agreement::agree_ephemeral(platform_private_key, &auth_unparsed_pub_key, |material| { + let mut hasher = digest::Context::new(&digest::SHA256); + hasher.update(material); + Ok(hasher.finish()) as Result + }) + .map_err(|_| PFError::Device("ECDH shared secret computation failed".into()))? + .map_err(|_| PFError::Device("Inner ECDH shared secret computation failed".into()))?; + + // 5. Encrypt PIN Hash + let pin_hash = digest::digest(&digest::SHA256, pin.as_bytes()); + let pin_hash_16 = &pin_hash.as_ref()[0..16]; + + let iv = [0u8; 16]; + let mut block = *GenericArray::from_slice(pin_hash_16); + + let shared_secret_bytes = shared_secret.as_ref(); + let mut encryptor = cbc::Encryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ); + encryptor.encrypt_block_mut(&mut block); + let pin_hash_enc = block.to_vec(); + + // 6. Send getPinUvAuthTokenUsingPinWithPermissions command (Subcommand 0x09) + + // 7. Send getPinUvAuthTokenUsingPinWithPermissions command (Subcommand 0x09) + + let mut payload = vec![CtapCommand::ClientPin as u8]; + let cose_key_bytes = self.encode_cose_key( + &platform_public_key_bytes.as_ref()[1..33], + &platform_public_key_bytes.as_ref()[33..65], + ); + + log::trace!( + "Encrypted PIN hash (first 4 bytes): {:?}", + &pin_hash_enc[..4] + ); + let payload_cbor = self.encode_client_pin_params( + ClientPinSubCommand::GetPinUvAuthTokenUsingPinWithPermissions, + &cose_key_bytes, + &pin_hash_enc, + Some(permissions.bits()), + rp_id, + ); + payload.extend(payload_cbor); + + log::debug!("Sending getPinUvAuthTokenUsingPinWithPermissions command..."); + let resp = self.send_cbor(CTAPHID_CBOR, &payload)?; + log::debug!( + "getPinUvAuthTokenUsingPinWithPermissions response: {:?}", + resp + ); + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + + if let Value::Map(m) = val { + log::debug!("getPinUvAuthTokenUsingPinWithPermissions response: {:?}", m); + match m.get(&Value::Integer(ClientPinResponseParam::PinToken as i128)) { + Some(Value::Bytes(token_enc)) => { + // Decrypt the PIN token using shared secret (AES-256-CBC, IV=0) + let mut token_buf = token_enc.clone(); + let decrypted = cbc::Decryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ) + .decrypt_padded_mut::(&mut token_buf) + .map_err(|_| PFError::Device("Failed to decrypt PIN token".into()))?; + log::info!("Successfully obtained and decrypted PIN token (Subcommand 0x09)."); + Ok(decrypted.to_vec()) + } + _ => Err(PFError::Device( + "pinUvAuthToken not found in response".into(), + )), + } + } else { + Err(PFError::Device("Unexpected response format".into())) + } + } + + pub fn set_pin(&self, new_pin: &str) -> Result<(), PFError> { + log::info!("Starting custom set_pin (Subcommand 0x03)..."); + + if new_pin.len() < 4 { + return Err(PFError::Device("PIN must be at least 4 characters".into())); + } + if new_pin.len() > 63 { + return Err(PFError::Device( + "PIN must be less than 64 characters".into(), + )); + } + + // 1. Get Authenticator Key Agreement + let auth_key_agreement = self.get_key_agreement()?; + + // 2. Generate Platform Key Pair (P-256) + let rng = ring::rand::SystemRandom::new(); + let platform_private_key = + agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng) + .map_err(|_| PFError::Device("Failed to generate platform ephemeral key".into()))?; + let platform_public_key_bytes = platform_private_key + .compute_public_key() + .map_err(|_| PFError::Device("Failed to compute platform public key".into()))?; + + // 3. Extract Authenticator Public Key + let (auth_x, auth_y) = if let Value::Map(m) = &auth_key_agreement { + let x = match m.get(&Value::Integer(-2)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement X coordinate".into())), + }; + let y = match m.get(&Value::Integer(-3)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement Y coordinate".into())), + }; + (x, y) + } else { + return Err(PFError::Device("Invalid KeyAgreement format".into())); + }; + + let mut auth_pub_key_bytes = vec![0x04]; + auth_pub_key_bytes.extend(auth_x); + auth_pub_key_bytes.extend(auth_y); + + let auth_unparsed_pub_key = + agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, auth_pub_key_bytes); + + // 4. Perform ECDH to get Shared Secret + let shared_secret = + agreement::agree_ephemeral(platform_private_key, &auth_unparsed_pub_key, |material| { + let mut hasher = digest::Context::new(&digest::SHA256); + hasher.update(material); + Ok(hasher.finish()) as Result + }) + .map_err(|_| PFError::Device("ECDH shared secret computation failed".into()))? + .map_err(|_| PFError::Device("Inner ECDH shared secret computation failed".into()))?; + + let shared_secret_bytes = shared_secret.as_ref(); + + // 5. Encrypt newPinEnc + let mut padded_new_pin = [0u8; 64]; + let bytes = new_pin.as_bytes(); + padded_new_pin[..bytes.len()].copy_from_slice(bytes); + + let iv = [0u8; 16]; + let mut new_pin_enc = Vec::new(); + let mut encryptor = cbc::Encryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ); + for chunk in padded_new_pin.chunks_exact(16) { + let mut block = *GenericArray::from_slice(chunk); + encryptor.encrypt_block_mut(&mut block); + new_pin_enc.extend_from_slice(&block); + } + + // 6. Calculate pinUvAuthParam: HMAC-SHA-256(shared_secret, newPinEnc)[0..16] + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, shared_secret_bytes); + let pin_uv_auth_param = hmac::sign(&hmac_key, &new_pin_enc).as_ref()[0..16].to_vec(); + + // 7. Send SetPin command + let cose_key_bytes = self.encode_cose_key( + &platform_public_key_bytes.as_ref()[1..33], + &platform_public_key_bytes.as_ref()[33..65], + ); + + let mut payload_cbor = vec![0xA5]; // Map(5) + payload_cbor + .extend(to_vec(&Value::Integer(ClientPinParam::PinUvAuthProtocol as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(1)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::SubCommand as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinSubCommand::SetPin as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::KeyAgreement as i128)).unwrap()); + payload_cbor.extend(cose_key_bytes); + payload_cbor + .extend(to_vec(&Value::Integer(ClientPinParam::PinUvAuthParam as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Bytes(pin_uv_auth_param)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::NewPinEnc as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Bytes(new_pin_enc)).unwrap()); + + let mut payload = vec![CtapCommand::ClientPin as u8]; + payload.extend(payload_cbor); + + log::debug!("Sending setPin command..."); + match self.send_cbor(CTAPHID_CBOR, &payload) { + Ok(_) => { + log::info!("Successfully set new PIN."); + Ok(()) + } + Err(e) => { + let err_str = e.to_string(); + log::error!("Failed to send setPin config: {}", err_str); + if err_str.contains("0x37") { + return Err(PFError::Device( + "New PIN violates policy (e.g. too short).".into(), + )); + } + Err(PFError::Device(format!("setPin failed: {}", e))) + } + } + } + + pub 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 { + return Err(PFError::Device("PIN must be at least 4 characters".into())); + } + if new_pin.len() > 63 { + return Err(PFError::Device( + "PIN must be less than 64 characters".into(), + )); + } + + // 1. Get Authenticator Key Agreement + let auth_key_agreement = self.get_key_agreement()?; + + // 2. Generate Platform Key Pair (P-256) + let rng = ring::rand::SystemRandom::new(); + let platform_private_key = + agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng) + .map_err(|_| PFError::Device("Failed to generate platform ephemeral key".into()))?; + let platform_public_key_bytes = platform_private_key + .compute_public_key() + .map_err(|_| PFError::Device("Failed to compute platform public key".into()))?; + + // 3. Extract Authenticator Public Key + let (auth_x, auth_y) = if let Value::Map(m) = &auth_key_agreement { + let x = match m.get(&Value::Integer(-2)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement X coordinate".into())), + }; + let y = match m.get(&Value::Integer(-3)) { + Some(Value::Bytes(b)) => b, + _ => return Err(PFError::Device("Invalid KeyAgreement Y coordinate".into())), + }; + (x, y) + } else { + return Err(PFError::Device("Invalid KeyAgreement format".into())); + }; + + let mut auth_pub_key_bytes = vec![0x04]; + auth_pub_key_bytes.extend(auth_x); + auth_pub_key_bytes.extend(auth_y); + + let auth_unparsed_pub_key = + agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, auth_pub_key_bytes); + + // 4. Perform ECDH to get Shared Secret + let shared_secret = + agreement::agree_ephemeral(platform_private_key, &auth_unparsed_pub_key, |material| { + let mut hasher = digest::Context::new(&digest::SHA256); + hasher.update(material); + Ok(hasher.finish()) as Result + }) + .map_err(|_| PFError::Device("ECDH shared secret computation failed".into()))? + .map_err(|_| PFError::Device("Inner ECDH shared secret computation failed".into()))?; + + let shared_secret_bytes = shared_secret.as_ref(); + + // 5. Encrypt current_pin hash + let pin_hash = digest::digest(&digest::SHA256, current_pin.as_bytes()); + let pin_hash_16 = &pin_hash.as_ref()[0..16]; + let iv = [0u8; 16]; + let mut block = *GenericArray::from_slice(pin_hash_16); + cbc::Encryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ) + .encrypt_block_mut(&mut block); + let pin_hash_enc = block.to_vec(); + + // 6. Encrypt newPinEnc + let mut padded_new_pin = [0u8; 64]; + let bytes = new_pin.as_bytes(); + padded_new_pin[..bytes.len()].copy_from_slice(bytes); + + let mut new_pin_enc = Vec::new(); + let mut encryptor = cbc::Encryptor::::new( + GenericArray::from_slice(shared_secret_bytes), + GenericArray::from_slice(&iv), + ); + for chunk in padded_new_pin.chunks_exact(16) { + let mut block = *GenericArray::from_slice(chunk); + encryptor.encrypt_block_mut(&mut block); + new_pin_enc.extend_from_slice(&block); + } + + // 7. Calculate pinUvAuthParam: HMAC-SHA-256(shared_secret, newPinEnc || pinHashEnc)[0..16] + let mut hmac_msg = Vec::new(); + hmac_msg.extend_from_slice(&new_pin_enc); + hmac_msg.extend_from_slice(&pin_hash_enc); + + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, shared_secret_bytes); + let pin_uv_auth_param = hmac::sign(&hmac_key, &hmac_msg).as_ref()[0..16].to_vec(); + + // 8. Send ChangePin command + let cose_key_bytes = self.encode_cose_key( + &platform_public_key_bytes.as_ref()[1..33], + &platform_public_key_bytes.as_ref()[33..65], + ); + + let mut payload_cbor = vec![0xA6]; // Map(6) + payload_cbor + .extend(to_vec(&Value::Integer(ClientPinParam::PinUvAuthProtocol as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(1)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::SubCommand as i128)).unwrap()); + payload_cbor + .extend(to_vec(&Value::Integer(ClientPinSubCommand::ChangePin as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::KeyAgreement as i128)).unwrap()); + payload_cbor.extend(cose_key_bytes); + payload_cbor + .extend(to_vec(&Value::Integer(ClientPinParam::PinUvAuthParam as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Bytes(pin_uv_auth_param)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::NewPinEnc as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Bytes(new_pin_enc)).unwrap()); + payload_cbor.extend(to_vec(&Value::Integer(ClientPinParam::PinHashEnc as i128)).unwrap()); + payload_cbor.extend(to_vec(&Value::Bytes(pin_hash_enc)).unwrap()); + + let mut payload = vec![CtapCommand::ClientPin as u8]; + payload.extend(payload_cbor); + + log::debug!("Sending changePin command..."); + match self.send_cbor(CTAPHID_CBOR, &payload) { + Ok(_) => { + log::info!("Successfully changed PIN."); + Ok(()) + } + Err(e) => { + let err_str = e.to_string(); + log::error!("Failed to send changePin config: {}", err_str); + if err_str.contains("0x31") { + return Err(PFError::Device("Invalid current PIN (0x31). Please check that you entered the correct PIN.".into())); + } + if err_str.contains("0x32") { + return Err(PFError::Device( + "PIN blocked (0x32). Device reset may be required.".into(), + )); + } + if err_str.contains("0x37") { + return Err(PFError::Device( + "New PIN violates policy (e.g. too short).".into(), + )); + } + Err(PFError::Device(format!("changePin failed: {}", e))) + } + } + } + /// Helper to sign the authenticatorConfig command fn sign_config_command( &self, @@ -510,9 +1074,556 @@ impl HidTransport { message.extend(sub_params_bytes); // Sign using provided PIN token - use ring::hmac; + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, pin_token); + let sig = hmac::sign(&hmac_key, &message); + sig.as_ref()[0..16].to_vec() + } + + fn encode_cose_key(&self, x: &[u8], y: &[u8]) -> Vec { + let mut bytes = vec![0xA5]; // Map(5) + bytes.extend(to_vec(&Value::Integer(1)).unwrap()); + bytes.extend(to_vec(&Value::Integer(2)).unwrap()); + bytes.extend(to_vec(&Value::Integer(3)).unwrap()); + bytes.extend(to_vec(&Value::Integer(-7)).unwrap()); + bytes.extend(to_vec(&Value::Integer(-1)).unwrap()); + bytes.extend(to_vec(&Value::Integer(1)).unwrap()); + bytes.extend(to_vec(&Value::Integer(-2)).unwrap()); + bytes.extend(to_vec(&Value::Bytes(x.to_vec())).unwrap()); + bytes.extend(to_vec(&Value::Integer(-3)).unwrap()); + bytes.extend(to_vec(&Value::Bytes(y.to_vec())).unwrap()); + bytes + } + + fn encode_client_pin_params( + &self, + sub_cmd: ClientPinSubCommand, + cose_key_bytes: &[u8], + pin_hash_enc: &[u8], + permissions: Option, + rp_id: Option, + ) -> Vec { + let mut count = 4; + if permissions.is_some() { + count += 1; + } + if rp_id.is_some() { + count += 1; + } + let mut bytes = vec![0xA0 | (count as u8)]; + bytes.extend(to_vec(&Value::Integer(ClientPinParam::PinUvAuthProtocol as i128)).unwrap()); + bytes.extend(to_vec(&Value::Integer(1)).unwrap()); + bytes.extend(to_vec(&Value::Integer(ClientPinParam::SubCommand as i128)).unwrap()); + bytes.extend(to_vec(&Value::Integer(sub_cmd as i128)).unwrap()); + bytes.extend(to_vec(&Value::Integer(ClientPinParam::KeyAgreement as i128)).unwrap()); + bytes.extend(cose_key_bytes); + bytes.extend(to_vec(&Value::Integer(ClientPinParam::PinHashEnc as i128)).unwrap()); + bytes.extend(to_vec(&Value::Bytes(pin_hash_enc.to_vec())).unwrap()); + if let Some(p) = permissions { + bytes.extend(to_vec(&Value::Integer(ClientPinParam::Permissions as i128)).unwrap()); + bytes.extend(to_vec(&Value::Integer(p as i128)).unwrap()); + } + if let Some(rp) = rp_id { + bytes.extend(to_vec(&Value::Integer(ClientPinParam::PermissionsRpId as i128)).unwrap()); + bytes.extend(to_vec(&Value::Text(rp)).unwrap()); + } + bytes + } + + pub fn credential_management_enumerate_rps( + &self, + pin: &str, + ) -> Result, PFError> { + log::info!("Starting custom credential_management_enumerate_rps..."); + + // 1. Get PIN token with CREDENTIAL_MANAGEMENT permission + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::CREDENTIAL_MANAGEMENT, + None, + )?; + + let mut all_rps = Vec::new(); + + // 2. EnumerateRpsBegin (Subcommand 0x02) + // let sub_params = BTreeMap::new(); + // let sub_params_bytes = to_vec(&Value::Map(sub_params.clone())).unwrap(); + + let pin_auth = self.sign_credential_mgmt_command( + &pin_token, + CredentialMgmtSubCommand::EnumerateRpsBegin as u8, + None, // sub_params_bytes + ); + + let mut mgmt_map = BTreeMap::new(); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommand as i128), + Value::Integer(CredentialMgmtSubCommand::EnumerateRpsBegin as i128), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::PinUvAuthProtocol as i128), + Value::Integer(1), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::PinUvAuthParam as i128), + Value::Bytes(pin_auth), + ); + + let mut payload = vec![CtapCommand::CredentialMgmt as u8]; + payload.extend(to_vec(&Value::Map(mgmt_map)).map_err(|e| PFError::Io(e.to_string()))?); + + let resp = match self.send_cbor(CTAPHID_CBOR, &payload) { + Ok(r) => r, + Err(e) => { + if e.to_string().contains("0x2E") { + log::info!("No credentials found on device (0x2E)"); + return Ok(Vec::new()); + } + return Err(e); + } + }; + + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + let mut total_rps = None; + + if let Value::Map(m) = &val { + let rp = m + .get(&Value::Integer(CredentialMgmtResponseParam::Rp as i128)) + .cloned() + .ok_or_else(|| { + PFError::Device("RP not found in EnumerateRpsBegin response".into()) + })?; + let rp_id_hash = match m.get(&Value::Integer( + CredentialMgmtResponseParam::RpIdHash as i128, + )) { + Some(Value::Bytes(b)) => b.clone(), + _ => { + return Err(PFError::Device( + "RpIdHash not found in EnumerateRpsBegin response".into(), + )); + } + }; + if let Some(Value::Integer(t)) = m.get(&Value::Integer( + CredentialMgmtResponseParam::TotalRps as i128, + )) { + total_rps = Some(*t as usize); + } + + all_rps.push(EnumerateRpResponse { + rp, + rp_id_hash, + total_rps, + }); + } + + // 3. EnumerateRpsGetNextRp (Subcommand 0x03) + let num_to_fetch = total_rps.unwrap_or(1); + while all_rps.len() < num_to_fetch { + let mut mgmt_map = BTreeMap::new(); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommand as i128), + Value::Integer(CredentialMgmtSubCommand::EnumerateRpsGetNextRp as i128), + ); + + let mut payload = vec![CtapCommand::CredentialMgmt as u8]; + payload.extend(to_vec(&Value::Map(mgmt_map)).map_err(|e| PFError::Io(e.to_string()))?); + + match self.send_cbor(CTAPHID_CBOR, &payload) { + Ok(resp) => { + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + if let Value::Map(m) = val { + let rp = m + .get(&Value::Integer(CredentialMgmtResponseParam::Rp as i128)) + .cloned() + .ok_or_else(|| { + PFError::Device( + "RP not found in EnumerateRpsGetNextRp response".into(), + ) + })?; + let rp_id_hash = match m.get(&Value::Integer( + CredentialMgmtResponseParam::RpIdHash as i128, + )) { + Some(Value::Bytes(b)) => b.clone(), + _ => { + return Err(PFError::Device( + "RpIdHash not found in EnumerateRpsGetNextRp response".into(), + )); + } + }; + all_rps.push(EnumerateRpResponse { + rp, + rp_id_hash, + total_rps, + }); + } + } + Err(e) => { + if e.to_string().contains("0x2E") { + break; + } + return Err(e); + } + } + } + + Ok(all_rps) + } + + pub fn credential_management_enumerate_credentials( + &self, + pin: &str, + rp_id_hash: &[u8], + ) -> Result, PFError> { + log::info!("Starting custom credential_management_enumerate_credentials..."); + + // 1. Get PIN token with CREDENTIAL_MANAGEMENT permission + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::CREDENTIAL_MANAGEMENT, + None, + )?; + + let mut all_creds = Vec::new(); + + // 2. EnumerateCredentialsBegin (Subcommand 0x04) + let mut sub_params = BTreeMap::new(); + sub_params.insert( + Value::Integer(0x01), // rpIdHash + Value::Bytes(rp_id_hash.to_vec()), + ); + let sub_params_bytes = to_vec(&Value::Map(sub_params.clone())).unwrap(); + + let pin_auth = self.sign_credential_mgmt_command( + &pin_token, + CredentialMgmtSubCommand::EnumerateCredentialsBegin as u8, + Some(&sub_params_bytes), + ); + + let mut mgmt_map = BTreeMap::new(); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommand as i128), + Value::Integer(CredentialMgmtSubCommand::EnumerateCredentialsBegin as i128), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommandParams as i128), + Value::Map(sub_params), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::PinUvAuthProtocol as i128), + Value::Integer(1), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::PinUvAuthParam as i128), + Value::Bytes(pin_auth), + ); + + let mut payload = vec![CtapCommand::CredentialMgmt as u8]; + payload.extend(to_vec(&Value::Map(mgmt_map)).map_err(|e| PFError::Io(e.to_string()))?); + + let resp = match self.send_cbor(CTAPHID_CBOR, &payload) { + Ok(r) => r, + Err(e) => { + if e.to_string().contains("0x2E") { + return Ok(Vec::new()); + } + return Err(e); + } + }; + + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + let mut total_creds = None; + + if let Value::Map(m) = &val { + let user = m + .get(&Value::Integer(CredentialMgmtResponseParam::User as i128)) + .cloned() + .ok_or_else(|| { + PFError::Device("User not found in EnumerateCredentialsBegin response".into()) + })?; + let credential_id = m + .get(&Value::Integer( + CredentialMgmtResponseParam::CredentialId as i128, + )) + .cloned() + .ok_or_else(|| { + PFError::Device( + "CredentialId not found in EnumerateCredentialsBegin response".into(), + ) + })?; + let public_key = m + .get(&Value::Integer( + CredentialMgmtResponseParam::PublicKey as i128, + )) + .cloned() + .ok_or_else(|| { + PFError::Device( + "PublicKey not found in EnumerateCredentialsBegin response".into(), + ) + })?; + if let Some(Value::Integer(t)) = m.get(&Value::Integer( + CredentialMgmtResponseParam::TotalCredentials as i128, + )) { + total_creds = Some(*t as usize); + } + + all_creds.push(EnumerateCredentialResponse { + user, + credential_id, + public_key, + total_credentials: total_creds, + }); + } + + // 3. EnumerateCredentialsGetNextCredential (Subcommand 0x05) + let num_to_fetch = total_creds.unwrap_or(1); + while all_creds.len() < num_to_fetch { + let mut mgmt_map = BTreeMap::new(); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommand as i128), + Value::Integer( + CredentialMgmtSubCommand::EnumerateCredentialsGetNextCredential as i128, + ), + ); + + let mut payload = vec![CtapCommand::CredentialMgmt as u8]; + payload.extend(to_vec(&Value::Map(mgmt_map)).map_err(|e| PFError::Io(e.to_string()))?); + + match self.send_cbor(CTAPHID_CBOR, &payload) { + Ok(resp) => { + let val: Value = from_slice(&resp).map_err(|e| PFError::Io(e.to_string()))?; + if let Value::Map(m) = val { + let user = m + .get(&Value::Integer(CredentialMgmtResponseParam::User as i128)) + .cloned() + .ok_or_else(|| { + PFError::Device( + "User not found in EnumerateCredentialsGetNextCredential response" + .into(), + ) + })?; + let credential_id = m + .get(&Value::Integer(CredentialMgmtResponseParam::CredentialId as i128)) + .cloned() + .ok_or_else(|| { + PFError::Device( + "CredentialId not found in EnumerateCredentialsGetNextCredential response" + .into(), + ) + })?; + let public_key = m + .get(&Value::Integer(CredentialMgmtResponseParam::PublicKey as i128)) + .cloned() + .ok_or_else(|| { + PFError::Device( + "PublicKey not found in EnumerateCredentialsGetNextCredential response" + .into(), + ) + })?; + + all_creds.push(EnumerateCredentialResponse { + user, + credential_id, + public_key, + total_credentials: total_creds, + }); + } + } + Err(e) => { + if e.to_string().contains("0x2E") { + break; + } + return Err(e); + } + } + } + + Ok(all_creds) + } + + pub fn credential_management_delete_credential( + &self, + pin: &str, + credential_id_map: Value, + ) -> Result<(), PFError> { + log::info!("Starting custom credential_management_delete_credential..."); + + // 1. Get PIN token with CREDENTIAL_MANAGEMENT permission + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::CREDENTIAL_MANAGEMENT, + None, + )?; + + // 2. DeleteCredential (Subcommand 0x06) + let mut sub_params = BTreeMap::new(); + sub_params.insert( + Value::Integer(0x02), // credentialId descriptor map + credential_id_map, + ); + let sub_params_bytes = to_vec(&Value::Map(sub_params.clone())).unwrap(); + + let pin_auth = self.sign_credential_mgmt_command( + &pin_token, + CredentialMgmtSubCommand::DeleteCredential as u8, + Some(&sub_params_bytes), + ); + + let mut mgmt_map = BTreeMap::new(); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommand as i128), + Value::Integer(CredentialMgmtSubCommand::DeleteCredential as i128), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::SubCommandParams as i128), + Value::Map(sub_params), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::PinUvAuthProtocol as i128), + Value::Integer(1), + ); + mgmt_map.insert( + Value::Integer(CredentialMgmtParam::PinUvAuthParam as i128), + Value::Bytes(pin_auth), + ); + + let mut payload = vec![CtapCommand::CredentialMgmt as u8]; + payload.extend(to_vec(&Value::Map(mgmt_map)).map_err(|e| PFError::Io(e.to_string()))?); + + self.send_cbor(CTAPHID_CBOR, &payload)?; + + Ok(()) + } + + fn sign_credential_mgmt_command( + &self, + pin_token: &[u8], + sub_cmd: u8, + sub_params_bytes: Option<&[u8]>, + ) -> Vec { + // Research into pico-fido firmware reveals a non-standard signing logic: + // 1. No 32-byte 0xff padding. + // 2. No command byte (0x0d). + // 3. For subcommands 0x01 (GetCredsMetadata) and 0x02 (EnumerateRpsBegin), only sign the subcommand byte. + // 4. For others, sign the subcommand byte followed by the CBOR-encoded SubCommandParams map. + + let mut message = vec![sub_cmd]; + if let Some(params) = sub_params_bytes { + if sub_cmd != CredentialMgmtSubCommand::GetCredsMetadata as u8 + && sub_cmd != CredentialMgmtSubCommand::EnumerateRpsBegin as u8 + { + message.extend(params); + } + } + + log::debug!( + "Custom CredentialMgmt signing for sub_cmd 0x{:02x}, message len: {}", + sub_cmd, + message.len() + ); + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, pin_token); let sig = hmac::sign(&hmac_key, &message); sig.as_ref()[0..16].to_vec() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_pin_command_ordering() { + // This test doesn't run HID IO, but verifies that our BTreeMap usage + // (which is used in get_pin_token and get_pin_token_with_permission) + // results in correct CBOR key ordering. + let mut map = BTreeMap::new(); + map.insert(Value::Integer(0x01), Value::Integer(1)); // pinProtocol + map.insert(Value::Integer(0x02), Value::Integer(8)); // subCommand (getPinUvAuthToken...) + map.insert(Value::Integer(0x03), Value::Map(BTreeMap::new())); // keyAgreement + map.insert(Value::Integer(0x04), Value::Bytes(vec![0u8; 16])); // pinHashEnc + map.insert(Value::Integer(0x09), Value::Integer(0x01)); // permissions + + let cbor = to_vec(&Value::Map(map)).unwrap(); + + // Canonical CBOR requires keys to be in ascending order. + // BTreeMap in Rust is already ordered by key. + // So 0x01, 0x02, 0x03, 0x04, 0x09 should be in order. + + // Let's check the first few bytes of the map + // 0xA5 (Map of 5) + // 0x01 (Key 1) ... + assert_eq!(cbor[0], 0xA5); + assert_eq!(cbor[1], 0x01); + // We just care that it's ordered for pico-fido + } + + #[test] + fn test_get_key_agreement_parsing_logic() { + use std::collections::BTreeMap; + // Simulate a response map where key 0x01 is the KeyAgreement (as per CTAP 2.1) + let mut inner_map = BTreeMap::new(); + inner_map.insert(Value::Integer(1), Value::Integer(2)); // kty: EC2 + inner_map.insert(Value::Integer(-1), Value::Integer(1)); // crv: P-256 + inner_map.insert(Value::Integer(-2), Value::Bytes(vec![0xAA; 32])); // x + inner_map.insert(Value::Integer(-3), Value::Bytes(vec![0xBB; 32])); // y + + let mut resp_map = BTreeMap::new(); + resp_map.insert( + Value::Integer(ClientPinResponseParam::KeyAgreement as i128), + Value::Map(inner_map), + ); + + let val = Value::Map(resp_map); + + // This mimics the logic in get_key_agreement + if let Value::Map(m) = val { + let key_agreement = m.get(&Value::Integer( + ClientPinResponseParam::KeyAgreement as i128, + )); + assert!(key_agreement.is_some()); + if let Some(Value::Map(km)) = key_agreement { + assert_eq!( + km.get(&Value::Integer(-2)), + Some(&Value::Bytes(vec![0xAA; 32])) + ); + } else { + panic!("KeyAgreement should be a map"); + } + } else { + panic!("Expected map"); + } + } + + #[test] + fn test_pin_hash_encryption_actually_encrypts() { + // Verify that our AES-CBC encryption actually modifies the data. + // This guards against the previous bug where encrypt_block_mut + // was called on a temporary copy (buffer.into()), discarding the result. + use aes::cipher::generic_array::GenericArray; + use cbc::cipher::{BlockEncryptMut, KeyIvInit}; + use ring::digest; + + let pin = "123456"; + let pin_hash = digest::digest(&digest::SHA256, pin.as_bytes()); + let pin_hash_16 = &pin_hash.as_ref()[0..16]; + + // Use a known key (32 bytes of zeros) and IV (16 bytes of zeros) + let key = [0u8; 32]; + let iv = [0u8; 16]; + + let mut block = *GenericArray::from_slice(pin_hash_16); + let original = block.clone(); + + let mut encryptor = cbc::Encryptor::::new( + GenericArray::from_slice(&key), + GenericArray::from_slice(&iv), + ); + encryptor.encrypt_block_mut(&mut block); + + // The encrypted block MUST differ from the original + assert_ne!( + block.as_slice(), + original.as_slice(), + "Encryption did not modify the block — the old bug is back!" + ); + } +} diff --git a/src/device/fido/mod.rs b/src/device/fido/mod.rs index 01a55dc..1617dda 100644 --- a/src/device/fido/mod.rs +++ b/src/device/fido/mod.rs @@ -9,26 +9,11 @@ use crate::{ error::PFError, }; use constants::*; -use ctap_hid_fido2::{ - Cfg, FidoKeyHidFactory, - fidokey::{FidoKeyHid, pin::Permission}, - public_key_credential_descriptor::PublicKeyCredentialDescriptor, -}; use hid::*; use serde_cbor_2::{Value, from_slice, to_vec}; use std::collections::BTreeMap; -// Fido functions that require pin: ( Uses ctap_hid_fido2 crate) - -fn get_device() -> Result { - let cfg = Cfg::init(); - FidoKeyHidFactory::create(&cfg).map_err(|e| { - format!( - "Could not connect to FIDO device. Is it plugged in? Error: {:?}", - e - ) - }) -} +// Fido functions that require pin: pub(crate) fn get_fido_info() -> Result { log::info!("Reading FIDO device info via custom GetInfo..."); @@ -326,19 +311,20 @@ pub(crate) fn change_fido_pin( current_pin: Option, new_pin: String, ) -> Result { - let device = get_device()?; + log::info!("Starting change_fido_pin (custom implementation)..."); + + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; match current_pin { Some(old) => { - device + transport .change_pin(&old, &new_pin) - .map_err(|e| format!("Failed to change PIN: {:?}", e))?; + .map_err(|e| e.to_string())?; Ok("PIN Changed Successfully".into()) } Option::None => { - device - .set_new_pin(&new_pin) - .map_err(|e| format!("Failed to set PIN: {:?}", e))?; + transport.set_pin(&new_pin).map_err(|e| e.to_string())?; Ok("PIN Set Successfully".into()) } } @@ -350,31 +336,28 @@ pub(crate) fn set_min_pin_length( ) -> Result { log::info!("Starting set_min_pin_length (custom implementation)..."); - // 1. Obtain PIN token using the library handle - let pin_token = { - let device = get_device()?; - - // Obtain a token with AuthenticatorConfiguration permission (CTAP 2.1) - match device.get_pinuv_auth_token_with_permission( - ¤t_pin, - Permission::AuthenticatorConfiguration, - ) { - Ok(token) => { - log::debug!("Successfully obtained PIN token with ACFG permission."); - token.key - } - Err(e) => { - log::error!("Failed to get PIN token with ACFG permission: {:?}", e); - return Err(format!("Failed to obtain PIN token: {:?}", e)); - } - } - // Library handle 'device' is dropped here, closing the HID session. - }; - - // 2. Open custom HidTransport and send command using the token because ctap-hid-fido2 has a bug where it sends CBOR map keys out of order (0x01, 0x03, 0x04, 0x02) instead of the required ascending order (0x01, 0x02, 0x03, 0x04). The pico-fido firmware strictly requires ascending order. + // 1. Open custom HidTransport let transport = HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + // 2. Obtain PIN token using the custom implementation + let pin_token = transport + .get_pin_token_with_permission( + ¤t_pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + ) + .map_err(|e| { + let err_str = e.to_string(); + log::error!("Failed to get PIN token with ACFG permission: {}", err_str); + if err_str.contains("0x2B") { + return "The device does not support FIDO 2.1 advanced configuration (Error 0x2B). Ensure your device firmware is up to date and supports this feature.".to_string(); + } + format!("Failed to obtain PIN token: {}", err_str) + })?; + + // 3. Send command using the token because ctap-hid-fido2 has a bug where it sends CBOR map keys out of order (0x01, 0x03, 0x04, 0x02) instead of the required ascending order (0x01, 0x02, 0x03, 0x04). The pico-fido firmware strictly requires ascending order. + transport .send_config_set_min_pin_length(&pin_token, min_pin_length) .map_err(|e| format!("Failed to set minimum PIN length: {}", e))?; @@ -386,42 +369,73 @@ pub(crate) fn set_min_pin_length( } pub(crate) fn get_credentials(pin: String) -> Result, String> { - let device = get_device()?; + log::info!("Listing FIDO credentials via custom implementation..."); - let rps = match device.credential_management_enumerate_rps(Some(&pin)) { - Ok(rps) => rps, - Err(e) => { - // CTAP2_ERR_NO_CREDENTIALS (0x2E) means no credentials exist - return empty list - let err_str = format!("{:?}", e); - if err_str.contains("0x2E") || err_str.contains("NO_CREDENTIALS") { - log::info!("No credentials stored on device (CTAP2_ERR_NO_CREDENTIALS)"); - return Ok(Vec::new()); - } - return Err(format!("Failed to enumerate Relying Parties: {:?}", e)); - } - }; + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + + let rps = transport + .credential_management_enumerate_rps(&pin) + .map_err(|e| format!("Failed to enumerate Relying Parties: {}", e))?; let mut all_credentials = Vec::new(); - for rp in rps { - let creds = device - .credential_management_enumerate_credentials(Some(&pin), &rp.rpid_hash) - .map_err(|e| { - format!( - "Failed to enumerate credentials for RP {}: {:?}", - rp.public_key_credential_rp_entity.id, e - ) - })?; + for rp_res in rps { + let rp_id = if let Value::Map(m) = &rp_res.rp { + match m.get(&Value::Text("id".into())) { + Some(Value::Text(s)) => s.clone(), + _ => "Unknown".to_string(), + } + } else { + "Unknown".to_string() + }; + + let rp_name = if let Value::Map(m) = &rp_res.rp { + match m.get(&Value::Text("name".into())) { + Some(Value::Text(s)) => s.clone(), + _ => rp_id.clone(), + } + } else { + rp_id.clone() + }; + + log::debug!("Enumerating credentials for RP: {}", rp_id); + + let creds = transport + .credential_management_enumerate_credentials(&pin, &rp_res.rp_id_hash) + .map_err(|e| format!("Failed to enumerate credentials for RP {}: {}", rp_id, e))?; for cred in creds { - all_credentials.push(StoredCredential { - credential_id: hex::encode(&cred.public_key_credential_descriptor.id), - rp_id: rp.public_key_credential_rp_entity.id.clone(), - rp_name: rp.public_key_credential_rp_entity.name.clone(), - user_name: cred.public_key_credential_user_entity.name.clone(), - user_display_name: cred.public_key_credential_user_entity.display_name.clone(), - user_id: hex::encode(&cred.public_key_credential_user_entity.id).clone(), - }); + let mut stored_cred = StoredCredential { + credential_id: "".to_string(), + rp_id: rp_id.clone(), + rp_name: rp_name.clone(), + user_name: "".to_string(), + user_display_name: "".to_string(), + user_id: "".to_string(), + }; + + // Parse User Map + if let Value::Map(m) = &cred.user { + if let Some(Value::Text(s)) = m.get(&Value::Text("name".into())) { + stored_cred.user_name = s.clone(); + } + if let Some(Value::Text(s)) = m.get(&Value::Text("displayName".into())) { + stored_cred.user_display_name = s.clone(); + } + if let Some(Value::Bytes(b)) = m.get(&Value::Text("id".into())) { + stored_cred.user_id = hex::encode(b); + } + } + + // Parse Credential ID Descriptor + if let Value::Map(m) = &cred.credential_id { + if let Some(Value::Bytes(b)) = m.get(&Value::Text("id".into())) { + stored_cred.credential_id = hex::encode(b); + } + } + + all_credentials.push(stored_cred); } } @@ -429,19 +443,22 @@ pub(crate) fn get_credentials(pin: String) -> Result, Stri } pub(crate) fn delete_credential(pin: String, credential_id_hex: String) -> Result { - let device = get_device()?; + log::info!("Deleting FIDO credential via custom implementation..."); + + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; let cred_id_bytes = hex::decode(&credential_id_hex) .map_err(|_| "Invalid Credential ID Hex string".to_string())?; - let descriptor = PublicKeyCredentialDescriptor { - ctype: "public-key".to_string(), - id: cred_id_bytes, - }; + // Create PublicKeyCredentialDescriptor map: { "type": "public-key", "id": } + let mut descriptor = BTreeMap::new(); + descriptor.insert(Value::Text("type".into()), Value::Text("public-key".into())); + descriptor.insert(Value::Text("id".into()), Value::Bytes(cred_id_bytes)); - device - .credential_management_delete_credential(Some(&pin), descriptor) - .map_err(|e| format!("Failed to delete credential: {:?}", e))?; + transport + .credential_management_delete_credential(&pin, Value::Map(descriptor)) + .map_err(|e| format!("Failed to delete credential: {}", e))?; Ok("Credential deleted successfully".into()) } @@ -674,17 +691,21 @@ pub fn write_config(config: AppConfigInput, pin: Option) -> Result Result, PFError> { + match transport.get_pin_token_with_permission( + pin_val, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + ) { Ok(token) => { log::debug!("Successfully obtained PIN token with ACFG permission."); - token.key + Ok(token) } Err(e) => { log::warn!( @@ -692,22 +713,17 @@ pub fn write_config(config: AppConfigInput, pin: Option) -> Result) -> Result) -> Result) -> Result) -> Result) -> Result