From 3ddf4026802beaf1bdbacb2cab3211f907bba447 Mon Sep 17 00:00:00 2001 From: kralonur Date: Thu, 14 May 2026 19:53:24 +0300 Subject: [PATCH] refactor(fido): gate hardware config by firmware version --- src/device/fido/mod.rs | 606 +++++++++++++++++++++++++++-------------- 1 file changed, 400 insertions(+), 206 deletions(-) diff --git a/src/device/fido/mod.rs b/src/device/fido/mod.rs index ef083ae..2b1417c 100644 --- a/src/device/fido/mod.rs +++ b/src/device/fido/mod.rs @@ -14,6 +14,10 @@ use hid::*; use serde_cbor_2::{Value, from_slice, to_vec}; use std::collections::BTreeMap; +const LEGACY_PHY_OPT_DIMMABLE: u16 = 0x02; +const LEGACY_PHY_OPT_DISABLE_POWER_RESET: u16 = 0x04; +const LEGACY_PHY_OPT_LED_STEADY: u16 = 0x08; + // Fido functions that require pin: pub(crate) fn get_fido_info() -> Result { @@ -313,6 +317,21 @@ 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 && 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, @@ -471,6 +490,15 @@ pub(crate) fn delete_credential(pin: String, credential_id_hex: String) -> Resul // 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 fn read_device_details() -> Result { log::info!("Starting FIDO device details read..."); @@ -483,35 +511,56 @@ pub fn read_device_details() -> Result { } })?; - let (aaguid_str, fw_version) = read_device_info(&transport)?; + let fido_info = read_device_info(&transport)?; log::info!( "Device identified: AAGUID={}, FW={}", - aaguid_str, - fw_version + fido_info.aaguid, + fido_info.firmware_version ); - let mem_stats = read_memory_stats(&transport)?; - if let Some((used, total)) = mem_stats { - log::debug!( - "Memory Stats: Used={}KB, Total={}KB", - used / 1024, - total / 1024 - ); + let supports_legacy_hardware_config = + firmware_supports_legacy_fido_hardware_config(&fido_info.firmware_version); + let management = read_management_info(&transport); + let config = AppConfig { + vid: format!("{:04X}", transport.vid), + pid: format!("{:04X}", transport.pid), + product_name: transport.product_name.clone(), + ..Default::default() + }; + let config = if supports_legacy_hardware_config { + read_legacy_physical_config(&transport, config) } else { - log::info!("Memory Stats: Not Available"); - } - - let config = read_physical_config(&transport)?; + config + }; + let mem_stats = if supports_legacy_hardware_config { + read_legacy_memory_stats(&transport).unwrap_or_else(|e| { + log::info!("Legacy FIDO memory stats unavailable: {}", e); + None + }) + } else { + None + }; log::info!("Successfully read all device details."); + let firmware_version = if fido_info.firmware_version != "0.0" { + fido_info.firmware_version + } else { + management + .as_ref() + .and_then(|info| info.firmware_version.clone()) + .unwrap_or_else(|| "Unknown".to_string()) + }; + Ok(FullDeviceStatus { info: DeviceInfo { - serial: "?".to_string(), // Serial number is not available through fido - flash_used: mem_stats.map(|(u, _)| u / 1024), - flash_total: mem_stats.map(|(_, t)| t / 1024), - firmware_version: fw_version, + serial: management + .and_then(|info| info.serial) + .unwrap_or_else(|| "Unknown".to_string()), + flash_used: mem_stats.map(|(used, _)| used / 1024), + flash_total: mem_stats.map(|(_, total)| total / 1024), + firmware_version, }, config, secure_boot: false, @@ -520,7 +569,7 @@ pub fn read_device_details() -> Result { }) } -fn read_device_info(transport: &HidTransport) -> Result<(String, String), PFError> { +fn read_device_info(transport: &HidTransport) -> Result { log::debug!("Sending GetInfo command (0x04)..."); let info_payload = [CtapCommand::GetInfo as u8]; let info_res = transport @@ -537,201 +586,252 @@ fn read_device_info(transport: &HidTransport) -> Result<(String, String), PFErro PFError::Io(e.to_string()) })?; - // NOTE: Key 0x03 is AAGUID, not the unique device Serial. - let aaguid_str = if let Value::Map(m) = &info_val { - m.get(&Value::Integer(0x03)) - .and_then(|v| { - if let Value::Bytes(b) = v { - Some(hex::encode_upper(b)) - } else { - None - } - }) - .unwrap_or_else(|| { - log::warn!("AAGUID not found in GetInfo response"); - "Unknown".into() - }) - } else { - "Unknown".into() - }; - - let fw_version = if let Value::Map(m) = &info_val { - m.get(&Value::Integer(0x0E)) - .and_then(|v| { - if let Value::Integer(i) = v { - Some(format!("{}.{}", (i >> 8) & 0xFF, i & 0xFF)) - } else { - None - } - }) - .unwrap_or_else(|| { - log::warn!("Firmware version not found in GetInfo response"); - "Unknown".into() - }) - } else { - "Unknown".into() - }; - - Ok((aaguid_str, fw_version)) + parse_fido_get_info(&info_val).map_err(PFError::Io) } -fn read_memory_stats(transport: &HidTransport) -> Result, PFError> { - log::debug!("Preparing Memory Stats vendor command..."); +fn read_management_info(transport: &HidTransport) -> Option { + // pico-fido v7.6 src/fido/cbor.c handles HID cmd 0xC2 as raw + // man_get_config() TLV bytes, not as CTAP CBOR with a status byte. + match transport.send_raw(CTAP_VENDOR_CONFIG_CMD, &[]) { + Ok(raw) => match parse_management_info(&raw) { + Ok(info) => Some(info), + Err(e) => { + log::warn!("Failed to parse FIDO management config: {}", e); + None + } + }, + Err(e) => { + log::info!("FIDO management config is not available: {}", e); + None + } + } +} +fn parse_management_info(raw: &[u8]) -> Result { + let data = if raw.first().map(|len| *len as usize) == Some(raw.len().saturating_sub(1)) { + &raw[1..] + } else { + raw + }; + + let mut info = ManagementInfo::default(); + let mut i = 0; + while i < data.len() { + if i + 2 > data.len() { + return Err("truncated management tag header".to_string()); + } + + let tag = data[i]; + let len = data[i + 1] as usize; + i += 2; + + if i + len > data.len() { + return Err(format!("truncated management tag 0x{:02X}", tag)); + } + + let val = &data[i..i + len]; + match tag { + 0x01 => info.usb_supported = parse_management_u16(val), + 0x02 => { + if val.len() == 4 { + info.serial = Some(hex::encode_upper(val)); + } + } + 0x03 => info.usb_enabled = parse_management_u16(val), + 0x05 => { + if val.len() >= 2 { + info.firmware_version = Some(format!("{}.{}", val[0], val[1])); + } + } + 0x0A => { + if let Some(locked) = val.first() { + info.config_locked = Some(*locked != 0); + } + } + _ => {} + } + + i += len; + } + + Ok(info) +} + +fn parse_management_u16(val: &[u8]) -> Option { + match val { + [single] => Some(*single as u16), + [hi, lo] => Some(u16::from_be_bytes([*hi, *lo])), + _ => None, + } +} + +fn read_legacy_memory_stats(transport: &HidTransport) -> Result, PFError> { let mut mem_req = BTreeMap::new(); mem_req.insert( - Value::Integer(1), // Sub-command key (usually 1) + Value::Integer(1), Value::Integer(MemorySubCommand::GetStats as i128), ); - let mem_cbor = to_vec(&Value::Map(mem_req)).map_err(|e| { - log::error!("Failed to encode Memory Stats CBOR: {}", e); - PFError::Io(format!("CBOR encode error: {}", e)) - })?; - + let mem_cbor = to_vec(&Value::Map(mem_req)).map_err(|e| PFError::Io(e.to_string()))?; let mut mem_payload = vec![VendorCommand::Memory as u8]; mem_payload.extend(mem_cbor); - log::debug!("Sending Memory Stats command..."); - let mem_res = transport - .send_cbor(CTAP_VENDOR_CBOR_CMD, &mem_payload) - .map_err(|e| { - // Error code 0x2B means the feature is not supported/removed in this firmware mode - if e.to_string().contains("0x2B") { - log::info!("Memory stats not supported by device firmware (0x2B)."); - return PFError::NoDevice; // We'll handle this specially - } - log::warn!("Failed to fetch memory stats (Vendor Cmd): {}", e); - PFError::Device(format!("Failed to fetch memory stats: {}", e)) - }); - - let mem_res = match mem_res { - Ok(res) => res, - Err(PFError::NoDevice) => return Ok(None), - Err(e) => return Err(e), - }; - - let mem_map: BTreeMap = if !mem_res.is_empty() { - from_slice(&mem_res).map_err(|e| { - log::error!("Failed to parse Memory Stats CBOR response: {}", e); - PFError::Io(format!("Failed to parse Memory Stats CBOR: {}", e)) - })? - } else { - BTreeMap::new() - }; + let mem_res = transport.send_cbor(CTAP_VENDOR_CBOR_CMD, &mem_payload)?; + if mem_res.is_empty() { + return Ok(None); + } + let mem_map: BTreeMap = + from_slice(&mem_res).map_err(|e| PFError::Io(e.to_string()))?; let used = mem_map .get(&(MemoryResponseKey::UsedSpace as i128)) - .cloned() + .copied() .unwrap_or(0) as u32; let total = mem_map .get(&(MemoryResponseKey::TotalSpace as i128)) - .cloned() + .copied() .unwrap_or(0) as u32; Ok(Some((used, total))) } -fn read_physical_config(transport: &HidTransport) -> Result { - log::debug!("Preparing Physical Config vendor command..."); - - // FIX: Only arguments in CBOR map +fn read_legacy_physical_config(transport: &HidTransport, mut config: AppConfig) -> AppConfig { let mut phy_params = BTreeMap::new(); phy_params.insert( - Value::Integer(1), // Sub-command key + Value::Integer(1), Value::Integer(PhysicalOptionsSubCommand::GetOptions as i128), ); - let phy_cbor = to_vec(&Value::Map(phy_params)).map_err(|e| { - log::error!("Failed to encode Physical Config CBOR: {}", e); - PFError::Io(format!("CBOR encode error: {}", e)) - })?; + let Ok(phy_cbor) = to_vec(&Value::Map(phy_params)) else { + return config; + }; let mut phy_payload = vec![VendorCommand::PhysicalOptions as u8]; phy_payload.extend(phy_cbor); - log::debug!("Sending Physical Config command..."); - let phy_res = transport - .send_cbor(CTAP_VENDOR_CBOR_CMD, &phy_payload) - .unwrap_or_else(|e| { - log::warn!("Failed to fetch physical config (Vendor Cmd): {}", e); - Vec::new() - }); - - let mut config = AppConfig { - vid: format!("{:04X}", transport.vid), - pid: format!("{:04X}", transport.pid), - product_name: transport.product_name.clone(), - ..Default::default() + let Ok(phy_res) = transport.send_cbor(CTAP_VENDOR_CBOR_CMD, &phy_payload) else { + return config; }; - if let Ok(Value::Map(m)) = from_slice(&phy_res) { - log::debug!("Parsed Physical Config map successfully"); - if let Some(Value::Integer(v)) = m.get(&Value::Text("gpio".into())) { - config.led_gpio = *v as u8; - } else { - log::warn!("No led_gpio in CBOR map"); - } + let Ok(Value::Map(m)) = from_slice::(&phy_res) else { + return config; + }; - if let Some(Value::Integer(v)) = m.get(&Value::Text("brightness".into())) { - config.led_brightness = *v as u8; - } else { - log::warn!("No led_brightness in CBOR map"); - } - } else if !phy_res.is_empty() { - log::warn!("Physical config response was not a valid CBOR map"); - } else { - log::debug!("Physical config response was empty or already handled."); + if let Some(Value::Integer(opts_raw)) = m.get(&Value::Integer(1)) { + let opts = *opts_raw as u16; + config.led_dimmable = opts & LEGACY_PHY_OPT_DIMMABLE != 0; + config.power_cycle_on_reset = opts & LEGACY_PHY_OPT_DISABLE_POWER_RESET == 0; + config.led_steady = opts & LEGACY_PHY_OPT_LED_STEADY != 0; } - Ok(config) + config } pub fn write_config(config: AppConfigInput, pin: Option) -> Result { log::info!("Starting FIDO write_config..."); - let pin_val = pin.as_deref().ok_or_else(|| { - log::error!("write_config called without any security PIN provided"); - PFError::Device( - "A security PIN is required to be set to change the configuration in fido mode".into(), - ) - })?; + if is_empty_config_input(&config) { + return Ok("No FIDO-only hardware configuration changes were needed.".to_string()); + } - // 1. Open custom HidTransport and obtain PIN token let transport = HidTransport::open().map_err(|e| { log::error!("Failed to open HID transport: {}", e); PFError::Device(format!("Could not open HID transport: {}", e)) })?; + let fido_info = read_device_info(&transport)?; + let supports_legacy_hardware_config = + firmware_supports_legacy_fido_hardware_config(&fido_info.firmware_version); + validate_fido_config_changes(&config, supports_legacy_hardware_config)?; + + let pin_val = pin.as_deref().ok_or_else(|| { + log::error!("write_config called without any security PIN provided"); + PFError::Device( + "A security PIN is required to change legacy FIDO hardware configuration.".into(), + ) + })?; + + write_legacy_hardware_config(&transport, &config, pin_val) +} + +fn is_empty_config_input(config: &AppConfigInput) -> bool { + config.vid.is_none() + && config.pid.is_none() + && config.product_name.is_none() + && config.led_gpio.is_none() + && config.led_brightness.is_none() + && config.touch_timeout.is_none() + && config.led_driver.is_none() + && config.led_dimmable.is_none() + && config.power_cycle_on_reset.is_none() + && config.led_steady.is_none() + && config.enable_secp256k1.is_none() +} + +fn validate_fido_config_changes( + config: &AppConfigInput, + supports_legacy_hardware_config: bool, +) -> Result<(), PFError> { + if !supports_legacy_hardware_config + && (config.vid.is_some() + || config.pid.is_some() + || config.product_name.is_some() + || config.led_gpio.is_some() + || config.led_brightness.is_some() + || config.touch_timeout.is_some() + || config.led_driver.is_some() + || 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(), + )); + } + } + + Ok(()) +} + +fn write_legacy_hardware_config( + transport: &HidTransport, + config: &AppConfigInput, + pin: &str, +) -> Result { let get_fresh_token = || -> 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."); - Ok(token) - } - Err(e) => { + transport + .get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + ) + .or_else(|e| { log::warn!( "Failed to get PIN token with ACFG permission (Error: {:?}). Falling back to standard token.", e ); - // Fallback to standard PIN token (Subcommand 0x05) - let token = transport.get_pin_token(pin_val).map_err(|e2| { - log::error!("Failed to obtain even a standard PIN token: {:?}", e2); - PFError::Device(format!("PIN token acquisition failed: {:?}", e2)) - })?; - log::debug!("Successfully obtained standard PIN token (fallback)."); - Ok(token) - } - } + transport.get_pin_token(pin) + }) }; - // 2. Send vendor commands using the token - - // VID/PID config if let (Some(vid_str), Some(pid_str)) = (&config.vid, &config.pid) { let vid = u16::from_str_radix(vid_str, 16).map_err(|e| PFError::Io(e.to_string()))?; let pid = u16::from_str_radix(pid_str, 16).map_err(|e| PFError::Io(e.to_string()))?; @@ -741,68 +841,47 @@ pub fn write_config(config: AppConfigInput, pin: Option) -> Result