From 6b5d591e284be25bc2098f1c8f4efe8d3e1f8df0 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Mon, 22 Jun 2026 21:26:39 +0530 Subject: [PATCH] feat: add support for RS-Keys firmware and add optito reset the device --- Cargo.lock | 2 +- README.md | 6 +- src/device/fido/hid.rs | 23 ++- src/device/fido/mod.rs | 32 +++- src/device/io.rs | 20 ++ src/device/rescue/constants.rs | 116 +++++++++++- src/device/rescue/mod.rs | 276 ++++++++++++++++++++++++++- src/device/types.rs | 48 +++++ src/ui/components/dialog.rs | 42 ++++- src/ui/components/sidebar.rs | 11 +- src/ui/rootview.rs | 12 +- src/ui/types.rs | 6 +- src/ui/views/config.rs | 335 ++++++++++++++++++++++++++++++++- src/ui/views/home.rs | 6 + src/ui/views/passkeys.rs | 172 ++++++++++++++++- static/icons/circle-alert.svg | 1 + static/icons/trash.svg | 1 + 17 files changed, 1059 insertions(+), 50 deletions(-) create mode 100644 static/icons/circle-alert.svg create mode 100644 static/icons/trash.svg diff --git a/Cargo.lock b/Cargo.lock index d2cf8db..36d537a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4133,7 +4133,7 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "picoforge" -version = "0.5.0" +version = "0.6.0" dependencies = [ "aes", "anyhow", diff --git a/README.md b/README.md index 1c8623e..e6ee14f 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ > Check application [Installation Wiki](https://github.com/librekeys/picoforge/wiki/Installation) for installation guide of the PicoForge app on your system. > > **Supported Firmwares:** -> - **pico-fido**: v7.0, v7.2, v7.4, v7.6 -> - **LibreKeys One**: v7.4.2 -> - **RSKeys**: v0.2.8 +> - **[pico-fido](https://github.com/polhenarejos/pico-fido)**: v7.0, v7.2, v7.4, v7.6 +> - **[LibreKeys One](https://github.com/librekeys/pico-fido-firmwares/releases)**: v7.4.2 +> - **[RSKeys](https://github.com/TheMaxMur/RS-Key)**: v0.2.8 > > **Configuration Support:** > - **pico-fido v7.0/v7.2** & **LibreKeys One v7.4.2**: Hardware configuration via FIDO mode is supported. diff --git a/src/device/fido/hid.rs b/src/device/fido/hid.rs index 213fddc..8b6a225 100644 --- a/src/device/fido/hid.rs +++ b/src/device/fido/hid.rs @@ -164,13 +164,24 @@ impl HidTransport { } pub fn send_cbor(&self, cmd: u8, payload: &[u8]) -> Result, PFError> { + self.send_cbor_with_timeout(cmd, payload, HID_TOTAL_TIMEOUT_MS) + } + + 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) + self.read_cbor_response(cmd, timeout_ms) } pub fn send_raw(&self, cmd: u8, payload: &[u8]) -> Result, PFError> { self.write_cbor_request(cmd, payload)?; - self.read_hid_response(cmd) + self.read_hid_response(cmd, HID_TOTAL_TIMEOUT_MS) + } + + 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(()) } fn write_cbor_request(&self, cmd: u8, payload: &[u8]) -> Result<(), PFError> { @@ -239,8 +250,8 @@ impl HidTransport { Ok(()) } - fn read_cbor_response(&self, cmd: u8) -> Result, PFError> { - let response_data = self.read_hid_response(cmd)?; + 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() { @@ -265,7 +276,7 @@ impl HidTransport { Ok(response_data[1..].to_vec()) } - fn read_hid_response(&self, cmd: u8) -> Result, PFError> { + fn read_hid_response(&self, cmd: u8, timeout_ms: i32) -> Result, PFError> { log::debug!("Waiting for response..."); let mut buf = [0u8; HID_REPORT_SIZE]; @@ -275,7 +286,7 @@ impl HidTransport { 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); + let timeout_duration = std::time::Duration::from_millis(timeout_ms as u64); // 1. Read First Packet (Loop to handle Keepalives) loop { diff --git a/src/device/fido/mod.rs b/src/device/fido/mod.rs index d39fbb4..d6d11cc 100644 --- a/src/device/fido/mod.rs +++ b/src/device/fido/mod.rs @@ -4,7 +4,7 @@ pub mod hid; use crate::{ device::types::{ AppConfig, AppConfigInput, DeviceInfo, DeviceMethod, FidoDeviceInfo, FullDeviceStatus, - StoredCredential, + StoredCredential, RSKEY_AAGUID, PICOFIDO_AAGUID, FirmwareType, }, error::PFError, }; @@ -488,6 +488,26 @@ pub(crate) fn delete_credential(pin: String, credential_id_hex: String) -> Resul Ok("Credential deleted successfully".into()) } +pub(crate) fn reset_device() -> Result { + log::info!("Starting FIDO authenticatorReset..."); + + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + + transport.reset().map_err(|e| { + let s = e.to_string(); + if s.contains("0x30") { + return "Reset not allowed. The device must be unplugged and re-plugged within 10 seconds before sending the reset command.".to_string(); + } + if s.contains("0x27") { + return "Reset declined. Touch was not confirmed on the device.".to_string(); + } + format!("Reset failed: {}", s) + })?; + + Ok("Device has been factory reset. All credentials and PIN have been erased.".to_string()) +} + // Custom Fido functions ( works only with pico-fido firmware ) #[derive(Debug, Default, Clone, PartialEq, Eq)] @@ -553,6 +573,14 @@ 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 @@ -566,6 +594,7 @@ pub fn read_device_details() -> Result { secure_boot: false, secure_lock: false, method: DeviceMethod::Fido, + firmware_type, }) } @@ -1041,6 +1070,7 @@ mod tests { power_cycle_on_reset: None, led_steady: None, enable_secp256k1: None, + led_order: None, } } diff --git a/src/device/io.rs b/src/device/io.rs index 61cd049..62dc9f3 100644 --- a/src/device/io.rs +++ b/src/device/io.rs @@ -59,6 +59,26 @@ pub fn delete_credential(pin: String, credential_id: String) -> Result Result { + fido::reset_device() +} + +pub fn read_led_config() -> Result { + rescue::read_led_config() +} + +pub fn write_led_status(status: u8, color: u8, brightness: u8, steady: bool) -> Result { + rescue::write_led_status(status, color, brightness, steady) +} + +pub fn read_management_config() -> Result { + rescue::read_management_config() +} + +pub fn write_management_config(enabled_mask: u16) -> Result { + rescue::write_management_config(enabled_mask) +} + pub fn enable_enterprise_attestation(pin: String) -> Result { fido::enable_enterprise_attestation(pin) } diff --git a/src/device/rescue/constants.rs b/src/device/rescue/constants.rs index 9f4c9ed..8ff25dc 100644 --- a/src/device/rescue/constants.rs +++ b/src/device/rescue/constants.rs @@ -1,4 +1,4 @@ -//! Constants, enums, bitflags and data structures for Rescue Application for pico-fido firmware. +//! Constants, enums, bitflags and data structures for Rescue and vendor applets. #![allow(unused)] // use serde::{Deserialize, Serialize}; @@ -91,10 +91,11 @@ pub enum PhyTag { LedGpio = 0x04, LedBrightness = 0x05, Opts = 0x06, - PresenceTimeout = 0x08, // Previously TAG_UP_BTN + PresenceTimeout = 0x08, UsbProduct = 0x09, Curves = 0x0A, LedDriver = 0x0C, + LedOrder = 0x0D, } impl PhyTag { @@ -109,6 +110,7 @@ impl PhyTag { 0x09 => Some(Self::UsbProduct), 0x0A => Some(Self::Curves), 0x0C => Some(Self::LedDriver), + 0x0D => Some(Self::LedOrder), _ => None, } } @@ -129,3 +131,113 @@ bitflags::bitflags! { const SECP256K1 = 0x08; } } + +// --- 4. Vendor/LED Applet (RS-Key specific) --- + +pub const VENDOR_LED_AID: &[u8] = &[0xF0, 0x00, 0x00, 0x00, 0x01]; + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VendorLedInstruction { + SetLed = 0x10, + GetLed = 0x11, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedColor { + Off = 0, + Red = 1, + Green = 2, + Blue = 3, + Yellow = 4, + Magenta = 5, + Cyan = 6, + White = 7, +} + +impl LedColor { + pub fn from_u8(val: u8) -> Option { + match val { + 0 => Some(Self::Off), + 1 => Some(Self::Red), + 2 => Some(Self::Green), + 3 => Some(Self::Blue), + 4 => Some(Self::Yellow), + 5 => Some(Self::Magenta), + 6 => Some(Self::Cyan), + 7 => Some(Self::White), + _ => None, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Off => "Off", + Self::Red => "Red", + Self::Green => "Green", + Self::Blue => "Blue", + Self::Yellow => "Yellow", + Self::Magenta => "Magenta", + Self::Cyan => "Cyan", + Self::White => "White", + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::Off, Self::Red, Self::Green, Self::Blue, + Self::Yellow, Self::Magenta, Self::Cyan, Self::White, + ] + } +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedStatus { + Idle = 0, + Processing = 1, + Touch = 2, + Boot = 3, +} + +impl LedStatus { + pub fn label(&self) -> &'static str { + match self { + Self::Idle => "Idle", + Self::Processing => "Processing", + Self::Touch => "Touch", + Self::Boot => "Boot", + } + } + + pub fn all() -> &'static [Self] { + &[Self::Idle, Self::Processing, Self::Touch, Self::Boot] + } +} + +// --- 5. Management Applet (Yubico-compatible, RS-Key) --- + +pub const MANAGEMENT_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x05, 0x27, 0x47, 0x11, 0x17]; + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagementInstruction { + ReadConfig = 0x1D, + WriteConfig = 0x1C, +} + +pub const MGMT_TAG_USB_SUPPORTED: u8 = 0x01; +pub const MGMT_TAG_SERIAL: u8 = 0x02; +pub const MGMT_TAG_USB_ENABLED: u8 = 0x03; +pub const MGMT_TAG_FORM_FACTOR: u8 = 0x04; +pub const MGMT_TAG_VERSION: u8 = 0x05; +pub const MGMT_TAG_DEVICE_FLAGS: u8 = 0x08; +pub const MGMT_TAG_CONFIG_LOCK: u8 = 0x0A; + +pub const USB_CAP_OTP: u16 = 0x0001; +pub const USB_CAP_U2F: u16 = 0x0002; +pub const USB_CAP_OPENPGP: u16 = 0x0008; +pub const USB_CAP_PIV: u16 = 0x0010; +pub const USB_CAP_OATH: u16 = 0x0020; +pub const USB_CAP_FIDO2: u16 = 0x0200; diff --git a/src/device/rescue/mod.rs b/src/device/rescue/mod.rs index 95466a1..70f6d55 100644 --- a/src/device/rescue/mod.rs +++ b/src/device/rescue/mod.rs @@ -10,8 +10,27 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use pcsc::{Context, Protocols, Scope, ShareMode}; use std::io::Cursor; +/// Differentiates between Pico-Fido and RS-Key firmwares based on the Rescue Applet SELECT response. +/// +/// **WARNING:** This is a temporary heuristic that relies on the major version byte (`major >= 8` implies RS-Key). +/// If Pico-Fido releases v8.x, this logic will silently fail and misidentify devices. +/// +/// TODO: Work with upstream RS-Key maintainers to expose a unique identity block or hardware string +/// in the SELECT response to reliably differentiate the firmwares in the long term. +fn detect_firmware_type(select_resp: &[u8]) -> FirmwareType { + if select_resp.len() >= 4 { + let major = select_resp[2]; + if major >= 8 { + return FirmwareType::RSKey; + } else { + return FirmwareType::PicoFido; + } + } + FirmwareType::Unknown +} + /// Connects to the first available reader and selects the Rescue Applet -fn connect_and_select() -> Result<(pcsc::Card, Vec), PFError> { +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) @@ -52,12 +71,15 @@ fn connect_and_select() -> Result<(pcsc::Card, Vec), PFError> { } log::info!("Successfully connected to Rescue Applet"); - Ok((card, rx.to_vec())) + let data = rx.to_vec(); + let fw_type = detect_firmware_type(&data); + log::info!("Detected firmware type: {:?}", fw_type); + Ok((card, data, fw_type)) } pub fn read_device_details() -> Result { log::info!("Reading full device details"); - let (card, select_resp) = connect_and_select()?; + let (card, select_resp, fw_type) = connect_and_select()?; log::info!("Select Response: {:?}", select_resp); @@ -213,6 +235,11 @@ pub fn read_device_details() -> Result { config.led_driver = Some(val[0]); } } + PhyTag::LedOrder => { + if !val.is_empty() { + config.led_order = Some(val[0]); + } + } } } i += len; @@ -236,6 +263,7 @@ pub fn read_device_details() -> Result { secure_boot: sb_enabled, secure_lock: sb_locked, method: DeviceMethod::Rescue, + firmware_type: fw_type, }) } @@ -332,7 +360,14 @@ pub fn write_config(config: AppConfigInput) -> Result { tlv.push(PhyTag::UsbProduct as u8); tlv.push(len as u8); tlv.extend_from_slice(name_bytes); - tlv.push(0x00); // Null terminator + 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); } // 2. Connect and Send @@ -343,7 +378,7 @@ pub fn write_config(config: AppConfigInput) -> Result { log::debug!("TLV payload size: {} bytes", tlv.len()); - let (card, _) = connect_and_select()?; + let (card, _, _) = connect_and_select()?; // APDU: 80 1C 01 00 [Lc] [Data] let mut apdu = vec![ @@ -368,7 +403,7 @@ pub fn write_config(config: AppConfigInput) -> Result { } pub fn reboot_device(to_bootsel: bool) -> Result { - let (card, _) = connect_and_select()?; + let (card, _, _) = connect_and_select()?; let param = if to_bootsel { RebootParam::Bootsel @@ -396,7 +431,7 @@ pub fn reboot_device(to_bootsel: bool) -> Result { /// UNSTABLE! (WIP) pub fn enable_secure_boot(lock: bool) -> Result { - let (card, _) = connect_and_select()?; + let (card, _, _) = connect_and_select()?; // APDU: 80 1D [KeyIndex] [LockBool] 00 // KeyIndex = 0 (Default), LockBool = 1 if true @@ -419,3 +454,230 @@ pub fn enable_secure_boot(lock: bool) -> Result { Err(PFError::Device(format!("Secure Boot failed: {:02X?}", rx))) } } + +// --- Vendor/LED Applet (RS-Key) --- + +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. +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 }) +} + +/// 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. +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))) + } +} + +// --- 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. +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) +} + +/// 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. +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 + ))) + } +} diff --git a/src/device/types.rs b/src/device/types.rs index 243e74c..b5f525c 100644 --- a/src/device/types.rs +++ b/src/device/types.rs @@ -1,6 +1,7 @@ #![allow(unused)] use serde::{Deserialize, Serialize}; +use std::fmt; struct PForgeState { device_info: DeviceInfo, @@ -30,6 +31,8 @@ pub struct AppConfig { pub power_cycle_on_reset: bool, pub led_steady: bool, pub enable_secp256k1: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub led_order: Option, } #[derive(Deserialize, Debug, Clone)] @@ -46,6 +49,7 @@ pub struct AppConfigInput { pub power_cycle_on_reset: Option, pub led_steady: Option, pub enable_secp256k1: Option, + pub led_order: Option, } #[derive(Serialize, Debug, Clone, PartialEq)] @@ -56,6 +60,7 @@ pub struct FullDeviceStatus { pub secure_boot: bool, pub secure_lock: bool, pub method: DeviceMethod, + pub firmware_type: FirmwareType, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] @@ -65,6 +70,49 @@ pub enum DeviceMethod { Rescue, } +/// Represents the recognized firmware variants running on the connected hardware token. +/// Used extensively to gate UI features, connection methods, and compatibility checks. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +pub enum FirmwareType { + PicoFido, + RSKey, + #[default] + Unknown, +} + +impl fmt::Display for FirmwareType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PicoFido => write!(f, "Pico-FIDO"), + Self::RSKey => write!(f, "RS-Key"), + Self::Unknown => write!(f, "Unknown"), + } + } +} + +/// The globally unique Authenticator Attestation GUID (AAGUID) assigned to RS-Key hardware. +pub const RSKEY_AAGUID: &str = "2479C7BF6B3056839EC80E8171A918B7"; +/// The globally unique Authenticator Attestation GUID (AAGUID) assigned to Pico-Fido hardware. +pub const PICOFIDO_AAGUID: &str = "89FB94B706C936739B7E30526D968145"; + +/// Aggregates the LED status configurations read from the RS-Key Vendor/LED applet. +/// Contains the global steady flag and a fixed array of `(color_code, brightness)` pairs +/// mapped chronologically to device statuses: [Idle, Processing, Touch, Boot]. +#[derive(Serialize, Debug, Default, Clone, PartialEq)] +pub struct LedStatusConfig { + pub steady: bool, + pub statuses: [(u8, u8); 4], +} + +/// Encapsulates the bitmasks defining USB application endpoints on the device. +/// The `usb_supported` mask indicates which applets the firmware is capable of running, +/// while `usb_enabled` reflects the active endpoints the device will enumerate on next boot. +#[derive(Serialize, Debug, Default, Clone, PartialEq)] +pub struct ManagementAppConfig { + pub usb_supported: u16, + pub usb_enabled: u16, +} + // Fido stuff: #[derive(Serialize, Debug, Clone, PartialEq)] diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index e46af03..d8c9844 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -8,7 +8,7 @@ use gpui_component::{ }; type PinPromptCallback = std::rc::Rc, &mut App)>; -type ConfirmCallback = std::rc::Rc, &mut App)>; +type ConfirmCallback = std::rc::Rc, &mut Window, &mut App)>; type ChangePinCallback = std::rc::Rc, &mut App)>; type SetPinCallback = std::rc::Rc, &mut App)>; @@ -17,6 +17,9 @@ type SetPinCallback = std::rc::Rc, &mut enum DialogPhase { Input, Loading, + /// Indicates the dialog is blocked on an asynchronous background task, + /// presenting a specific dynamic status message to guide the user (e.g. "Waiting for touch..."). + LoadingWithMessage(String), Success(String), Error(String), } @@ -92,7 +95,7 @@ impl Render for PinPromptContent { ) .into_any_element(), - DialogPhase::Loading => v_flex() + DialogPhase::Loading | DialogPhase::LoadingWithMessage(_) => v_flex() .gap_4() .child(self.description.clone()) .child(Input::new(&self.pin_input).disabled(true)) @@ -365,7 +368,7 @@ impl Render for ConfirmContent { ) .into_any_element(), - DialogPhase::Loading => v_flex() + DialogPhase::Loading | DialogPhase::LoadingWithMessage(_) => v_flex() .gap_4() .child(self.message.clone()) .child( @@ -414,11 +417,11 @@ impl Render for ConfirmContent { Button::new("ok") .with_variant(ok_variant) .label(ok_label) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { if let Some(h) = handle.upgrade() { h.update(cx, |this, cx| this.set_loading(cx)); } - on_ok(handle.clone(), cx); + on_ok(handle.clone(), window, cx); }), ), ) @@ -447,11 +450,11 @@ impl Render for ConfirmContent { Button::new("ok") .with_variant(ok_variant) .label(ok_label) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { if let Some(h) = handle.upgrade() { h.update(cx, |this, cx| this.set_loading(cx)); } - on_ok(handle.clone(), cx); + on_ok(handle.clone(), window, cx); }), ), ) @@ -468,7 +471,7 @@ pub fn open_confirm( ok_variant: ButtonVariant, window: &mut Window, cx: &mut App, - on_ok: impl Fn(WeakEntity, &mut App) + 'static, + on_ok: impl Fn(WeakEntity, &mut Window, &mut App) + 'static, ) { let title_str = SharedString::from(title.to_string()); let dialog_title = title_str.clone(); @@ -576,7 +579,7 @@ impl Render for ChangePinContent { ) .into_any_element(), - DialogPhase::Loading => v_flex() + DialogPhase::Loading | DialogPhase::LoadingWithMessage(_) => v_flex() .gap_4() .child("Enter your current PIN and choose a new one.") .child( @@ -887,7 +890,7 @@ impl Render for SetPinContent { ) .into_any_element(), - DialogPhase::Loading => v_flex() + DialogPhase::Loading | DialogPhase::LoadingWithMessage(_) => v_flex() .gap_4() .child("Choose a PIN for your pico-key.") .child( @@ -1100,6 +1103,13 @@ pub struct StatusContent { } impl StatusContent { + /// Transitions the dialog into a loading state while displaying a custom, dynamic status message. + /// Useful for multi-step background operations where user context needs to be updated. + pub fn set_loading(&mut self, msg: impl Into, cx: &mut Context) { + self.phase = DialogPhase::LoadingWithMessage(msg.into()); + cx.notify(); + } + pub fn set_success(&mut self, msg: String, cx: &mut Context) { self.phase = DialogPhase::Success(msg); cx.notify(); @@ -1178,6 +1188,18 @@ impl Render for StatusContent { .into_any_element() } + DialogPhase::LoadingWithMessage(msg) => v_flex() + .gap_4() + .items_center() + .child(msg.clone()) + .child( + Button::new("loading") + .primary() + .label("Applying...") + .loading(true), + ) + .into_any_element(), + _ => v_flex() .gap_4() .items_center() diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index f57e098..9136bd5 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -221,15 +221,18 @@ impl AppSidebar { .child({ let (text, color_bg, color_text) = if let Some(status) = &state.status { + let is_rskey = status.firmware_type == crate::device::types::FirmwareType::RSKey; + let fw_label = if is_rskey { "RS-Key" } else { "Pico-FIDO" }; + if status.method == DeviceMethod::Fido { - ("Online - Fido", rgb(0xf59e0b), rgb(0xffffff)) + (format!("Online - FIDO ({})", fw_label), rgb(0xf59e0b), rgb(0xffffff)) } else { - ("Online", rgb(0x16a34a), rgb(0xffffff)) + (format!("Online - {}", fw_label), rgb(0x16a34a), rgb(0xffffff)) } } else if state.error.is_some() { - ("Error", rgb(0xd97706), rgb(0xffffff)) + ("Error".to_string(), rgb(0xd97706), rgb(0xffffff)) } else { - ("Offline", rgb(0xef4444), rgb(0xffffff)) + ("Offline".to_string(), rgb(0xef4444), rgb(0xffffff)) }; div() diff --git a/src/ui/rootview.rs b/src/ui/rootview.rs index 90d1f93..3a5677c 100644 --- a/src/ui/rootview.rs +++ b/src/ui/rootview.rs @@ -55,7 +55,7 @@ impl ApplicationRoot { .map(|s| s.info.serial != status.info.serial) .unwrap_or(true); - self.device.status = Some(status); + self.device.status = Some(status.clone()); self.device.error = None; if device_changed { @@ -72,6 +72,14 @@ impl ApplicationRoot { } } + if status.firmware_type == crate::device::types::FirmwareType::RSKey && status.method == crate::device::types::DeviceMethod::Rescue { + self.device.led_status = io::read_led_config().ok(); + self.device.management_apps = io::read_management_config().ok(); + } else { + self.device.led_status = None; + self.device.management_apps = None; + } + if let Some(config_view) = &self.views.config && let Some(window) = window { @@ -85,6 +93,8 @@ impl ApplicationRoot { self.device.status = None; self.device.error = Some(format!("{}", e)); self.device.fido_info = None; + self.device.led_status = None; + self.device.management_apps = None; } } self.device.loading = false; diff --git a/src/ui/types.rs b/src/ui/types.rs index 962e6f1..d842127 100644 --- a/src/ui/types.rs +++ b/src/ui/types.rs @@ -1,5 +1,5 @@ use crate::{ - device::types::{FidoDeviceInfo, FullDeviceStatus}, + device::types::{FidoDeviceInfo, FullDeviceStatus, LedStatusConfig, ManagementAppConfig}, ui::views::{config::ConfigView, passkeys::PasskeysView}, }; use gpui::{Entity, Pixels, SharedString, px}; @@ -17,6 +17,8 @@ pub enum ActiveView { pub struct DeviceConnectionState { pub status: Option, pub fido_info: Option, + pub led_status: Option, + pub management_apps: Option, pub error: Option, pub loading: bool, } @@ -26,6 +28,8 @@ impl DeviceConnectionState { Self { status: None, fido_info: None, + led_status: None, + management_apps: None, error: None, loading: false, } diff --git a/src/ui/views/config.rs b/src/ui/views/config.rs index 93630b2..8316f50 100644 --- a/src/ui/views/config.rs +++ b/src/ui/views/config.rs @@ -1,5 +1,6 @@ use crate::device::types::{AppConfigInput, DeviceMethod}; use crate::device::{fido, io}; +use crate::device::rescue::constants::{LedColor, LedStatus, USB_CAP_OTP, USB_CAP_U2F, USB_CAP_OPENPGP, USB_CAP_PIV, USB_CAP_OATH, USB_CAP_FIDO2}; use crate::ui::components::dialog::PinPromptContent; use crate::ui::components::{card::Card, dialog, dialog::StatusContent, page_view::PageView}; use crate::ui::rootview::ApplicationRoot; @@ -73,6 +74,14 @@ pub struct ConfigView { enable_secp256k1: bool, loading: bool, is_custom_vendor: bool, + + // RS-Key specific state + led_status_steady: bool, + led_status_colors: [u8; 4], + led_status_brightness: [u8; 4], + usb_apps_supported: u16, + usb_apps_enabled: u16, + _task: Option>, } @@ -194,6 +203,24 @@ impl ConfigView { let touch_timeout_input = cx.new(|cx| InputState::new(window, cx).default_value(current_touch_timeout.clone())); + let mut led_status_steady = false; + let mut led_status_colors = [0; 4]; + let mut led_status_brightness = [0; 4]; + if let Some(led) = &device.led_status { + led_status_steady = led.steady; + for i in 0..4 { + led_status_colors[i] = led.statuses[i].0; + led_status_brightness[i] = led.statuses[i].1; + } + } + + let mut usb_apps_supported = 0; + let mut usb_apps_enabled = 0; + if let Some(apps) = &device.management_apps { + usb_apps_supported = apps.usb_supported; + usb_apps_enabled = apps.usb_enabled; + } + Self { root, vendor_select, @@ -210,6 +237,11 @@ impl ConfigView { enable_secp256k1: config.map(|c| c.enable_secp256k1).unwrap_or(true), loading: false, is_custom_vendor, + led_status_steady, + led_status_colors, + led_status_brightness, + usb_apps_supported, + usb_apps_enabled, _task: None, } } @@ -384,6 +416,7 @@ impl ConfigView { power_cycle_on_reset: None, led_steady: None, enable_secp256k1: None, + led_order: None, }; let vid = self.vid_input.read(cx).text().to_string(); @@ -554,6 +587,19 @@ impl ConfigView { ); }); + if let Some(led) = &device.led_status { + self.led_status_steady = led.steady; + for i in 0..4 { + self.led_status_colors[i] = led.statuses[i].0; + self.led_status_brightness[i] = led.statuses[i].1; + } + } + + if let Some(apps) = &device.management_apps { + self.usb_apps_supported = apps.usb_supported; + self.usb_apps_enabled = apps.usb_enabled; + } + cx.notify(); } @@ -794,6 +840,266 @@ impl ConfigView { .icon(Icon::default().path("icons/settings.svg")) .child(content) } + + /// Renders the RS-Key-specific LED configuration card. + /// + /// This dynamic panel iterates through the device's LED operating statuses (Idle, Processing, + /// Touch, Boot) and provides interactive widgets to customize the active color and brightness + /// level for each. Only displayed when an RS-Key firmware is detected. + fn render_rskey_led_card(&mut self, cx: &mut Context, is_fido: bool) -> impl IntoElement { + let theme = cx.theme(); + let mut rows = v_flex().gap_4(); + + // Steady switch + let steady_listener = cx.listener(|this, checked, _, cx| { + this.led_status_steady = *checked; + cx.notify(); + }); + + rows = rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("Global Steady Mode").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Keep status LEDs on constantly"), + ), + ) + .child( + Switch::new("rskey-led-steady") + .checked(self.led_status_steady) + .disabled(is_fido) + .on_click(steady_listener), + ), + ); + + rows = rows.child(div().h_px().bg(theme.border)); + + // Create rows for each status + for (i, status) in LedStatus::all().iter().enumerate() { + let color_val = self.led_status_colors[i]; + let brightness_val = self.led_status_brightness[i]; + + // A dropdown for color, and a simple + / - or slider for brightness. + // But we don't have a simple dropdown component that works inline easily without a state entity per row. + // Let's just use a simple label and two buttons to cycle color, or we can instantiate 4 SelectStates? + // Since we need SelectState for dropdowns, we can't easily spawn them dynamically in render without keeping them in the struct. + // A simpler approach for the UI: Just show the current color text and + / - buttons to cycle it, and + / - for brightness. + // This avoids adding 4 SelectStates and 4 SliderStates to ConfigView. + let c_i = i; + let cycle_color_listener = cx.listener(move |this, _, _, cx| { + let mut c = this.led_status_colors[c_i]; + c = (c + 1) % 8; + this.led_status_colors[c_i] = c; + cx.notify(); + }); + + let dec_bright_listener = cx.listener(move |this, _, _, cx| { + let mut b = this.led_status_brightness[c_i]; + if b > 0 { b -= 1; } + this.led_status_brightness[c_i] = b; + cx.notify(); + }); + + let inc_bright_listener = cx.listener(move |this, _, _, cx| { + let mut b = this.led_status_brightness[c_i]; + if b < 15 { b += 1; } + this.led_status_brightness[c_i] = b; + cx.notify(); + }); + + let color_name = LedColor::from_u8(color_val).map(|c| c.label()).unwrap_or("Unknown"); + + rows = rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child(div().w_24().child(status.label())) + .child( + gpui_component::h_flex().gap_2().items_center() + .child( + Button::new(gpui::SharedString::from(format!("color-btn-{}", i))) + .child(color_name) + .disabled(is_fido) + .on_click(cycle_color_listener) + ) + .child(div().w_4()) + .child( + Button::new(gpui::SharedString::from(format!("bdec-btn-{}", i))) + .child("-") + .disabled(is_fido || brightness_val == 0) + .on_click(dec_bright_listener) + ) + .child(div().w_8().flex().justify_center().child(brightness_val.to_string())) + .child( + Button::new(gpui::SharedString::from(format!("binc-btn-{}", i))) + .child("+") + .disabled(is_fido || brightness_val == 15) + .on_click(inc_bright_listener) + ) + ) + ); + } + + // Add a save button for LED status + rows = rows.child(div().h_px().bg(theme.border)); + rows = rows.child( + gpui_component::h_flex().justify_end().child( + Button::new("apply-rskey-leds") + .child("Save LED Status") + .disabled(is_fido || self.loading) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_rskey_led_settings(window, cx); + })) + ) + ); + + Card::new() + .title("Status LED Colors") + .description("Configure LED colors and brightness per device state") + .icon(Icon::default().path("icons/palette.svg")) + .child(rows) + } + + 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; + + self.loading = true; + let handle = dialog::open_status_dialog("Applying LED Configuration...", window, cx); + 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 { + io::write_led_status(i as u8, colors[i], brightnesses[i], steady)?; + } + Ok::<_, crate::error::PFError>(()) + }).await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + 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(); + }); + })); + } + + /// Renders the RS-Key-specific USB Applications management card. + /// + /// Provides toggles to enable or disable USB endpoints such as U2F, OATH, PIV, and OpenPGP. + /// Safely computes the bitmasks and writes to the Management applet. Gated by hardware support. + fn render_rskey_apps_card(&mut self, cx: &mut Context, is_fido: bool) -> impl IntoElement { + let theme = cx.theme(); + let mut rows = v_flex().gap_4(); + + let apps = [ + ("FIDO2", USB_CAP_FIDO2), + ("OATH", USB_CAP_OATH), + ("PIV", USB_CAP_PIV), + ("OpenPGP", USB_CAP_OPENPGP), + ("U2F", USB_CAP_U2F), + ("OTP", USB_CAP_OTP), + ]; + + for (name, cap) in apps { + let is_supported = (self.usb_apps_supported & cap) != 0; + let is_enabled = (self.usb_apps_enabled & cap) != 0; + + let toggle_listener = cx.listener(move |this, checked, _, cx| { + if *checked { + this.usb_apps_enabled |= cap; + } else { + this.usb_apps_enabled &= !cap; + } + cx.notify(); + }); + + rows = rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child(name).child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(if is_supported { "Supported" } else { "Not Supported by Firmware" }), + ), + ) + .child( + Switch::new(gpui::SharedString::from(format!("app-toggle-{}", cap))) + .checked(is_enabled) + .disabled(is_fido || !is_supported) + .on_click(toggle_listener), + ), + ); + } + + rows = rows.child(div().h_px().bg(theme.border)); + rows = rows.child( + gpui_component::h_flex().justify_end().child( + Button::new("apply-rskey-apps") + .child("Save USB Applications") + .disabled(is_fido || self.loading) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_rskey_apps_settings(window, cx); + })) + ) + ); + + Card::new() + .title("USB Applications") + .description("Enable or disable specific USB features") + .icon(Icon::default().path("icons/microchip.svg")) + .child(rows) + } + + 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 { + io::write_management_config(mask) + }).await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + 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); + }); + } + } + cx.notify(); + }); + })); + } } impl Render for ConfigView { @@ -845,16 +1151,32 @@ impl Render for ConfigView { .render_options_card(cx, is_fido, hardware_config_disabled) .into_any_element(); - let theme = cx.theme(); - let identity_card = self - .render_identity_card(theme, is_fido, hardware_config_disabled) + .render_identity_card(cx.theme(), is_fido, hardware_config_disabled) .into_any_element(); - let touch_card = self.render_touch_card(theme, is_fido).into_any_element(); + let touch_card = self.render_touch_card(cx.theme(), is_fido).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(&crate::device::types::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(); + grid_children.push(rskey_led); + grid_children.push(rskey_apps); + } + + let theme = cx.theme(); + PageView::build( "Configuration", "Customize device settings and behavior.", @@ -865,10 +1187,7 @@ impl Render for ConfigView { .grid() .grid_cols(columns) .gap_6() - .child(identity_card) - .child(led_card) - .child(touch_card) - .child(options_card), + .children(grid_children), ) .child( gpui_component::h_flex().justify_end().pt_4().child( diff --git a/src/ui/views/home.rs b/src/ui/views/home.rs index 89843b3..9116835 100644 --- a/src/ui/views/home.rs +++ b/src/ui/views/home.rs @@ -110,6 +110,12 @@ impl HomeView { theme, true, )) + .child(Self::render_kv( + "Firmware Type", + status.firmware_type.to_string(), + theme, + false, + )) .child(Self::render_kv( "VID:PID", format!("{}:{}", config.vid, config.pid), diff --git a/src/ui/views/passkeys.rs b/src/ui/views/passkeys.rs index de9b586..9f0942e 100644 --- a/src/ui/views/passkeys.rs +++ b/src/ui/views/passkeys.rs @@ -13,7 +13,7 @@ use directories::UserDirs; use gpui::prelude::FluentBuilder; use gpui::*; use gpui_component::Disableable; -use gpui_component::button::{Button, ButtonVariant, ButtonVariants}; +use gpui_component::button::{Button, ButtonVariant, ButtonVariants, ButtonCustomVariant}; use gpui_component::{ ActiveTheme, Icon, Placement, Sizable, StyledExt, Theme, WindowExt, badge::Badge, @@ -223,7 +223,7 @@ impl PasskeysView { ButtonVariant::Danger, window, cx, - move |dialog_handle, cx| { + move |dialog_handle, _, cx| { let _ = view_handle.update(cx, |this, cx| { this.execute_delete(cred_id.clone(), pin_str.clone(), dialog_handle, cx); }); @@ -986,9 +986,9 @@ impl PasskeysView { ); Card::new() - .title("Enterprise Attestation Certificate") - .icon(Icon::default().path("icons/scroll-text.svg")) - .description("Manage the device enterprise attestation") + .title("Enterprise Attestation") + .description("Configure enterprise-specific features") + .icon(Icon::default().path("icons/shield-check.svg")) .child( v_flex() .gap_3() @@ -998,6 +998,55 @@ impl PasskeysView { ) } + fn render_reset_device_row(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + + let header = gpui_component::h_flex() + .items_center() + .justify_between() + .w_full() + .gap_4() + .child( + v_flex() + .gap_1() + .child( + div() + .text_base() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.foreground) + .child("Factory Reset"), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Erase all passkeys, credentials, and PIN. Cannot be undone."), + ), + ) + .child( + Button::new("reset-device") + .icon(Icon::default().path("icons/circle-alert.svg")) + .child("Reset Device") + .custom( + ButtonCustomVariant::new(cx) + .color(theme.danger.into()) + .hover(theme.danger_hover.into()) + .active(theme.danger_active.into()) + .foreground(theme.danger_foreground.into()), + ) + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| { + this.open_reset_dialog(window, cx); + })), + ); + + Card::new() + .title("Reset") + .description("Perform a destructive factory reset") + .icon(Icon::default().path("icons/trash.svg")) + .child(header) + } + fn render_no_device(&self, theme: &Theme) -> impl IntoElement { div() .flex() @@ -1032,6 +1081,116 @@ impl PasskeysView { .into_any_element() } + /// Triggers the confirmation flow for a hardware factory reset. + /// + /// Warns the user of the destructive nature of this action (all credentials, passkeys, + /// and PINs will be irrecoverably erased) via a GPUI modal dialog. If confirmed, + /// it transitions to `execute_reset` to begin the 10-second touch confirmation window. + fn open_reset_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view_handle = cx.entity().downgrade(); + + dialog::open_confirm( + "Factory Reset Device", + "Are you sure you want to completely erase your device? This will permanently delete ALL passkeys, credentials, and your PIN. This action cannot be undone.".to_string(), + "Reset Device", + ButtonVariant::Danger, + window, + cx, + move |_dialog_handle, window, cx| { + // Close the confirm dialog before opening the status dialog + window.close_dialog(cx); + // When they click confirm, we swap to a status dialog for the reconnect wizard + let _ = view_handle.update(cx, |this, cx| { + this.execute_reset(window, cx); + }); + }, + ); + } + + /// Orchestrates the underlying FIDO factory reset protocol asynchronously. + /// + /// Changes the UI to a loading/status phase instructing the user to unplug, replug, + /// and touch the key within 10 seconds. Monitors the reset task and propagates any + /// success or error state back to the UI thread upon completion. + fn execute_reset(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading { + return; + } + self.loading = true; + + let status_handle = dialog::open_status_dialog("Resetting Device...", window, cx); + let entity = cx.entity().downgrade(); + + let _ = status_handle.update(cx, |d, cx| { + d.set_loading("Unplug your security key, then plug it back in within 10 seconds.", cx); + }); + + self._task = Some(cx.spawn(async move |_, cx| { + // Wait for unplug/replug + let reconnected = cx.background_executor().spawn(async move { + let start = std::time::Instant::now(); + // 1. Wait for unplug + while start.elapsed().as_secs() < 15 { + std::thread::sleep(std::time::Duration::from_millis(200)); + if crate::device::fido::hid::HidTransport::open().is_err() { + break; + } + } + + // 2. Wait for replug + while start.elapsed().as_secs() < 15 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if crate::device::fido::hid::HidTransport::open().is_ok() { + return true; + } + } + false + }).await; + + if !reconnected { + let _ = entity.update(cx, |this, cx| { + this.loading = false; + let _ = status_handle.update(cx, |d, cx| { + d.set_error("Timeout waiting for device reconnection. Reset canceled.".to_string(), cx); + }); + cx.notify(); + }); + return; + } + + // Tell user to touch + let _ = status_handle.update(cx, |d, cx| { + d.set_loading("Touch your security key now to confirm the reset...", cx); + }); + + // Execute reset + let result = cx.background_executor().spawn(async move { + io::reset_device() + }).await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(msg) => { + log::info!("Device Reset: {}", msg); + this.lock_storage(cx); // clear cached pin/creds + let _ = status_handle.update(cx, |d, cx| { + d.set_success(msg, cx); + }); + cx.emit(PasskeysEvent::Notification("Device reset successfully".into())); + } + Err(e) => { + log::error!("Error resetting device: {}", e); + let _ = status_handle.update(cx, |d, cx| { + d.set_error(format!("Reset failed: {}", e), cx); + }); + } + } + cx.notify(); + }); + })); + } + fn render_pin_management(&self, cx: &mut Context) -> impl IntoElement { let status_row = self.render_pin_status_row(cx).into_any_element(); let min_len_row = self.render_min_pin_length_row(cx).into_any_element(); @@ -1595,7 +1754,8 @@ impl Render for PasskeysView { .gap_6() .child(self.render_pin_management(cx)) .child(self.render_stored_passkeys(cx)) - .child(self.render_enterprise_attestation(cx)); + .child(self.render_enterprise_attestation(cx)) + .child(self.render_reset_device_row(cx)); let theme = cx.theme(); diff --git a/static/icons/circle-alert.svg b/static/icons/circle-alert.svg new file mode 100644 index 0000000..b3f62b9 --- /dev/null +++ b/static/icons/circle-alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/icons/trash.svg b/static/icons/trash.svg new file mode 100644 index 0000000..8a6d8cf --- /dev/null +++ b/static/icons/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file