merge:PR 103 that adds RS-Key firmware support and fixes bugs

This commit is contained in:
Suyog Tandel
2026-07-19 22:14:10 +05:30
17 changed files with 531 additions and 269 deletions
+2
View File
@@ -8,10 +8,12 @@
- [jetcookies](https://github.com/jetcookies): Contributed `package.nix` file for nix packaging of the application and maintains the nix flake of the application.
- [Sylvain Pelissier](https://github.com/sylvainpelissier): Worked on the Enterprise attestation features.
- [kralonur](https://github.com/kralonur): Improved Pico-FIDO 7.6 compatibility and preserved legacy FIDO hardware configuration support.
- [TheMaxMur](https://github.com/TheMaxMur): Add RS-Key support.
**Third-party Libraries**
- [Pico FIDO](https://github.com/polhenarejos/pico-fido) - The firmware this tool configures
- [RS-Key](https://github.com/TheMaxMur/RS-Key) - The second firmware this tool configures
- [GPUI](https://www.gpui.rs/) - UI framework written in rust
- [gpui-component](https://longbridge.github.io/gpui-component/) - UI components for GPUI framework
- [pcsc-rust](https://github.com/bluetech/pcsc-rust) - Smart card interface
+4 -1
View File
@@ -20,7 +20,10 @@ 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
hidapi = "2.6" # For fido2 interface operations but non-standard commands
# macos-shared-device opens the FIDO HID non-exclusively; without it macOS holds
# FIDO devices exclusively and hidapi's open fails (0xE00002C5), stranding the
# whole FIDO transport. No-op on Linux/Windows. See hid_darwin_set_open_exclusive.
hidapi = { version = "2.6", features = ["macos-shared-device"] } # fido2 + non-standard cmds
serde_cbor_2 = "0.13"
rand = "0.10"
bitflags = "2.13.0"
+116
View File
@@ -0,0 +1,116 @@
//! Shared parsing for the RS-Key LED status block.
//!
//! Both the CCID Vendor/LED applet (`GET LED 0x11`) and the FIDO CTAPHID
//! `0x41 CONFIG_READ` (target LED) return the same `EF_LED_CONF` block. Its
//! current layout is `[steady, (effect, color, brightness, speed) × 4]`
//! (17 bytes); older firmware returns a 13-byte (pre-speed) or 9-byte
//! (pre-effect) block. The per-status stride is `(len - 1) / 4`, mirroring
//! RS-Key's own `rsk_led::load_block`. Only `color` and `brightness` are
//! surfaced to the config UI.
/// Number of device-status slots (idle, processing, touch, boot).
const N_STATUS: usize = 4;
/// Parse an `EF_LED_CONF` block into `(steady, [(color, brightness); 4])`.
///
/// `data` is the raw config block with no CBOR wrapper or status-word suffix.
/// Returns `None` when the block is too short to hold four status records
/// (stride `< 2`), so a malformed/truncated read fails cleanly rather than
/// reporting garbage colours.
pub fn parse_led_block(data: &[u8]) -> Option<(bool, [(u8, u8); N_STATUS])> {
if data.is_empty() {
return None;
}
let stride = (data.len() - 1) / N_STATUS;
if stride < 2 {
return None;
}
// color then brightness sit right after the optional leading effect byte:
// stride >= 3 (with effect) puts them at record offset +1/+2, the pre-effect
// stride-2 block starts with colour at offset +0.
let color_off = if stride >= 3 { 1 } else { 0 };
let steady = data[0] != 0;
let mut statuses = [(0u8, 0u8); N_STATUS];
for (i, slot) in statuses.iter_mut().enumerate() {
let base = 1 + stride * i + color_off;
*slot = (*data.get(base)?, *data.get(base + 1)?);
}
Some((steady, statuses))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_current_17_byte_block() {
// [steady, (effect, color, brightness, speed) × 4]
let block = [
0x01, // steady
0x00, 0x02, 0x40, 0x00, // idle: green, br 0x40
0x01, 0x03, 0x20, 0x05, // proc: blue, br 0x20
0x02, 0x04, 0x10, 0x0F, // touch: yellow, br 0x10
0x00, 0x01, 0x08, 0x00, // boot: red, br 0x08
];
let (steady, statuses) = parse_led_block(&block).unwrap();
assert!(steady);
assert_eq!(statuses, [(2, 0x40), (3, 0x20), (4, 0x10), (1, 0x08)]);
}
#[test]
fn effect_byte_is_not_mistaken_for_colour() {
// Regression: a 17-byte block whose effect bytes differ from the colours.
// The old stride-2 parse read the effect byte as the colour.
let mut block = [0u8; 17];
block[0] = 0; // steady = false
for i in 0..4 {
block[1 + 4 * i] = 0x03; // effect (would be misread as colour)
block[2 + 4 * i] = (i as u8) + 1; // colour 1..=4
block[3 + 4 * i] = 0x11 * (i as u8 + 1); // brightness
block[4 + 4 * i] = 0x00; // speed
}
let (steady, statuses) = parse_led_block(&block).unwrap();
assert!(!steady);
assert_eq!(statuses, [(1, 0x11), (2, 0x22), (3, 0x33), (4, 0x44)]);
}
#[test]
fn parses_pre_speed_13_byte_block() {
// [steady, (effect, color, brightness) × 4]
let block = [
0x00, // steady
0x00, 0x02, 0x40, // idle
0x00, 0x03, 0x20, // proc
0x00, 0x04, 0x10, // touch
0x00, 0x01, 0x08, // boot
];
let (steady, statuses) = parse_led_block(&block).unwrap();
assert!(!steady);
assert_eq!(statuses, [(2, 0x40), (3, 0x20), (4, 0x10), (1, 0x08)]);
}
#[test]
fn parses_pre_effect_9_byte_block() {
// [steady, (color, brightness) × 4]
let block = [
0x01, // steady
0x02, 0x40, // idle
0x03, 0x20, // proc
0x04, 0x10, // touch
0x01, 0x08, // boot
];
let (steady, statuses) = parse_led_block(&block).unwrap();
assert!(steady);
assert_eq!(statuses, [(2, 0x40), (3, 0x20), (4, 0x10), (1, 0x08)]);
}
#[test]
fn rejects_blocks_too_short_for_four_statuses() {
assert!(parse_led_block(&[]).is_none());
assert!(parse_led_block(&[0x01]).is_none());
assert!(parse_led_block(&[0x01, 0x02, 0x40, 0x03, 0x20, 0x04, 0x10, 0x01]).is_none());
}
}
+4 -1
View File
@@ -1,6 +1,9 @@
//! Shared COSE algorithm/curve/key-parameter definitions and firmware-version parsing.
//! Shared COSE algorithm/curve/key-parameter definitions, firmware-version
//! parsing, and the RS-Key LED status-block codec.
pub mod cose;
pub mod led;
pub mod version;
pub use led::parse_led_block;
pub use version::FirmwareVersion;
+3 -2
View File
@@ -718,8 +718,9 @@ pub const RSKEY_CTAPHID_VENDOR_CMD: u8 = 0x41;
/// RS-Key CONFIG_READ sub-command ID (0x0D).
///
/// Reads device configuration over FIDO. Supports DEV_CONF (0x00),
/// PHY (0x01), and LED (0x02) targets. Ungated — no PIN needed.
/// Reads device configuration over FIDO. Supports PHY (0x01) and LED (0x02)
/// targets only; DEV_CONF (0x00) is write-only over FIDO (read it via the CCID
/// Management applet). Returns a CBOR `{1: blob}` map. Ungated — no PIN needed.
pub const RSKEY_CONFIG_READ: u8 = 0x0D;
/// RS-Key CONFIG_WRITE sub-command ID (0x0C).
+121 -109
View File
@@ -598,31 +598,12 @@ pub(crate) struct ManagementInfo {
pub(crate) fn read_rskey_management_info(
transport: &HidTransport,
) -> Result<ManagementInfo, PFError> {
match transport.rs_key_config_read(RSKEY_CFG_TARGET_DEV_CONF) {
Ok(raw) if raw.len() > 1 => {
let data = if raw.first().copied() == Some(raw.len().saturating_sub(1) as u8) {
&raw[1..]
} else {
&raw[..]
};
parse_management_info(data).map_err(|e| {
PFError::Device(format!("Failed to parse RS-Key management config: {e}"))
})
}
Ok(_) => Err(PFError::Device(
"RS-Key FIDO management config response too short".to_string(),
)),
Err(_) => {
// DEV_CONF target not readable — fall back to legacy
// 0xC2 management info read (same as pico-fido).
read_management_info(transport).ok_or_else(|| {
PFError::Device(
"Failed to read management info over FIDO (0x41 and 0xC2 both rejected)"
.to_string(),
)
})
}
}
// Enabled-apps info is NOT readable over the 0x41 CONFIG_READ path — the
// firmware exposes only PHY/LED there and rejects DEV_CONF. Both RS-Key and
// pico-fido answer the CTAPHID vendor command 0xC2 (logical READ_CONFIG)
// with the Management DeviceInfo TLV, so read it there directly.
read_management_info(transport)
.ok_or_else(|| PFError::Device("Failed to read management info over FIDO".to_string()))
}
/// Read full device status (info, config, security flags) over the FIDO HID transport.
@@ -908,27 +889,16 @@ fn read_legacy_physical_config(transport: &HidTransport, mut config: AppConfig)
/// Read PHY configuration from an RS-Key via CTAPHID 0x41 CONFIG_READ.
///
/// Falls back to returning the unchanged config if the command is not
/// supported by the device.
/// supported by the device (pre-v0.3.1 firmware) or the transport errors.
fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) -> AppConfig {
let Ok(raw) = transport.rs_key_config_read(RSKEY_CFG_TARGET_PHY) else {
log::info!("RS-Key FIDO config read unavailable (transport error)");
// `rs_key_config_read` unwraps the CBOR `{1: blob}` and returns the raw
// `EF_PHY` TLV record — a bare `TAG LEN VALUE` sequence, no length prefix.
let Ok(data) = transport.rs_key_config_read(RSKEY_CFG_TARGET_PHY) else {
log::info!("RS-Key FIDO config read unavailable (pre-v0.3.1 firmware or transport error)");
return config;
};
if raw.len() <= 1 {
log::info!(
"RS-Key FIDO config read unavailable (response len={}, likely pre-v0.3.1 firmware)",
raw.len()
);
return config;
}
let data = if raw.first().copied() == Some(raw.len().saturating_sub(1) as u8) {
&raw[1..]
} else {
&raw[..]
};
let data = &data[..];
let mut i = 0;
while i + 1 < data.len() {
if i + 2 > data.len() {
@@ -948,13 +918,13 @@ fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) -
config.pid = format!("{:04X}", u16::from_be_bytes([field_data[2], field_data[3]]));
}
RSKEY_PHY_TAG_LED_GPIO if !field_data.is_empty() => {
config.led_gpio = field_data[0];
config.led_gpio = Some(field_data[0]);
}
RSKEY_PHY_TAG_LED_BRIGHTNESS if !field_data.is_empty() => {
config.led_brightness = field_data[0];
config.led_brightness = Some(field_data[0]);
}
RSKEY_PHY_TAG_PRESENCE_TIMEOUT if !field_data.is_empty() => {
config.touch_timeout = field_data[0];
config.touch_timeout = Some(field_data[0]);
}
RSKEY_PHY_TAG_USB_PRODUCT => {
let product_str = std::str::from_utf8(field_data)
@@ -969,12 +939,14 @@ fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) -
config.led_steady = opts & RSKEY_OPT_LED_STEADY != 0;
}
RSKEY_PHY_TAG_CURVES if field_data.len() == 4 => {
config.raw_curves_mask = Some(u32::from_be_bytes([
let mask = u32::from_be_bytes([
field_data[0],
field_data[1],
field_data[2],
field_data[3],
]));
]);
config.raw_curves_mask = Some(mask);
config.enable_secp256k1 = mask & 0x08 != 0; // SECP256K1, mirrors the CCID read
}
RSKEY_PHY_TAG_LED_DRIVER if !field_data.is_empty() => {
config.led_driver = Some(field_data[0]);
@@ -999,8 +971,9 @@ fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) -
/// Build a PHY TLV blob from `AppConfigInput` for RS-Key CONFIG_WRITE.
///
/// The TLV format matches the Rescue PHY record and is sent as-is
/// to the RS-Key 0x41 CONFIG_WRITE handler.
fn build_rskey_phy_tlv(config: &AppConfigInput) -> Vec<u8> {
/// to the RS-Key 0x41 CONFIG_WRITE handler. Errors if a field can't fit
/// the record (e.g. an over-long product name).
fn build_rskey_phy_tlv(config: &AppConfigInput) -> Result<Vec<u8>, PFError> {
let mut tlv = Vec::new();
if let (Some(vid_str), Some(pid_str)) = (&config.vid, &config.pid)
@@ -1055,6 +1028,13 @@ fn build_rskey_phy_tlv(config: &AppConfigInput) -> Vec<u8> {
if let Some(name) = config.product_name.as_deref().filter(|n| !n.is_empty()) {
let bytes = name.as_bytes();
// Firmware accepts a product record of 1..=33 bytes (name + trailing NUL),
// so the name must be <= 32 bytes — reject rather than emit a wrapped length.
if bytes.len() + 1 > 33 {
return Err(PFError::Device(
"Product name too long (max 32 bytes).".into(),
));
}
tlv.push(RSKEY_PHY_TAG_USB_PRODUCT);
tlv.push((bytes.len() + 1) as u8);
tlv.extend_from_slice(bytes);
@@ -1099,7 +1079,7 @@ fn build_rskey_phy_tlv(config: &AppConfigInput) -> Vec<u8> {
tlv.push(val);
}
tlv
Ok(tlv)
}
/// Write PHY config to an RS-Key via CTAPHID 0x41 CONFIG_WRITE.
@@ -1108,23 +1088,25 @@ fn write_rskey_config(
config: &AppConfigInput,
pin: &str,
) -> Result<String, PFError> {
let tlv = build_rskey_phy_tlv(config);
let tlv = build_rskey_phy_tlv(config)?;
if tlv.is_empty() {
return Ok("No RS-Key configuration changes were needed.".to_string());
}
// Probe: CONFIG_READ (0x41 subcommand 0x0D) is ungated and confirms
// the device supports the 0x41 CONFIG_WRITE/CONFIG_READ commands
// (RS-Key v0.3.1+). Pre-v0.3.1 devices return a CTAP error byte, which
// we detect as a response with len <= 1.
let cfg_read_resp = transport.rs_key_config_read(RSKEY_CFG_TARGET_PHY)?;
if cfg_read_resp.len() <= 1 {
return Err(PFError::Device(
"This RS-Key firmware does not support FIDO configuration. \
Please use Rescue mode (CCID/PCSC) or update to RS-Key v0.3.1+."
.into(),
));
}
// Probe: CONFIG_READ (0x41 subcommand 0x0D) is ungated and, on success,
// confirms the device supports the 0x41 CONFIG_WRITE/CONFIG_READ commands
// (RS-Key v0.3.1+). Pre-v0.3.1 firmware rejects it with a CTAP error, which
// surfaces here as `Err`. A supported device may legitimately return an
// empty PHY blob, so success alone is the signal — never the blob length.
transport
.rs_key_config_read(RSKEY_CFG_TARGET_PHY)
.map_err(|_| {
PFError::Device(
"This RS-Key firmware does not support FIDO configuration. \
Please use Rescue mode (CCID/PCSC) or update to RS-Key v0.3.1+."
.into(),
)
})?;
let pin_token = transport
.get_pin_token_with_permission(pin, PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, None)
@@ -1215,6 +1197,12 @@ fn is_empty_config_input(config: &AppConfigInput) -> bool {
&& config.power_cycle_on_reset.is_none()
&& config.led_steady.is_none()
&& config.enable_secp256k1.is_none()
// RS-Key-only PHY fields — `build_rskey_phy_tlv` writes these, so a
// change to any of them alone must not be treated as a no-op.
&& config.raw_curves_mask.is_none()
&& config.led_order.is_none()
&& config.enabled_usb_itf.is_none()
&& config.led_num.is_none()
}
fn validate_fido_config_changes(
@@ -1477,47 +1465,15 @@ const RSKEY_LED_CONF_LEN: usize = 17;
/// [`LedStatusConfig`] type, keeping only color and brightness
/// for backward compatibility with the Rescue LED UI.
pub(crate) fn read_rskey_led_config(transport: &HidTransport) -> Result<LedStatusConfig, PFError> {
let raw = transport.rs_key_config_read(RSKEY_CFG_TARGET_LED)?;
if raw.len() < RSKEY_LED_CONF_LEN {
return Err(PFError::Device(format!(
"LED config response too short: {} bytes (expected {})",
raw.len(),
RSKEY_LED_CONF_LEN,
)));
}
let data = if raw.first().copied() == Some(raw.len().saturating_sub(1) as u8) {
&raw[1..]
} else {
&raw[..]
};
if data.len() < 9 {
return Err(PFError::Device(format!(
"LED config payload too short: {} bytes",
data.len(),
)));
}
let steady = data[0] != 0;
let statuses = if data.len() >= RSKEY_LED_CONF_LEN {
// Full block: [steady, (effect, color, brightness, speed) × N]
let mut s = [(0u8, 0u8); 4];
for (i, slot) in s.iter_mut().enumerate() {
*slot = (data[2 + 4 * i], data[3 + 4 * i]); // color, brightness
}
s
} else {
// Legacy 9-byte block: [steady, (color, brightness) × N]
let mut s = [(0u8, 0u8); 4];
for (i, slot) in s.iter_mut().enumerate() {
let off = 1 + 2 * i;
if off + 1 < data.len() {
*slot = (data[off], data[off + 1]);
}
}
s
};
// `rs_key_config_read` unwraps the CBOR `{1: blob}`, so `data` is the raw
// `EF_LED_CONF` block. `parse_led_block` handles the 17/13/9-byte layouts.
let data = transport.rs_key_config_read(RSKEY_CFG_TARGET_LED)?;
let (steady, statuses) = crate::hal::common::parse_led_block(&data).ok_or_else(|| {
PFError::Device(format!(
"LED config response too short: {} bytes",
data.len()
))
})?;
log::info!(
"RS-Key FIDO LED config: steady={}, statuses={:?}",
@@ -1539,14 +1495,20 @@ pub(crate) fn write_rskey_led_config(
config: &LedStatusConfig,
pin: &str,
) -> Result<String, PFError> {
// Read-modify-write: overwrite only steady/color/brightness so a WS2812 effect
// + speed set out-of-band (e.g. `rsk led`) survives a colour change. Fall back
// to a fresh solid block (effect/speed = 0) if the current one can't be read.
let mut block = [0u8; RSKEY_LED_CONF_LEN];
if let Ok(current) = transport.rs_key_config_read(RSKEY_CFG_TARGET_LED)
&& current.len() >= RSKEY_LED_CONF_LEN
{
block.copy_from_slice(&current[..RSKEY_LED_CONF_LEN]);
}
block[0] = if config.steady { 0x01 } else { 0x00 };
for (i, &(color, brightness)) in config.statuses.iter().enumerate() {
let off = 1 + 4 * i;
block[off] = 0x00; // effect = solid
block[off + 1] = color & 0x07;
block[off + 2] = brightness;
block[off + 3] = 0x00; // speed = default
// effect (1+4i) and speed (4+4i) are left untouched.
block[2 + 4 * i] = color & 0x07;
block[3 + 4 * i] = brightness;
}
let pin_token = transport.get_pin_token_with_permission(
@@ -1971,4 +1933,54 @@ mod tests {
c.vid = Some("FEFF".to_string());
assert!(!is_empty_config_input(&c));
}
#[test]
fn test_is_empty_config_input_counts_rskey_only_fields() {
// A change to any RS-Key-only PHY field alone must not be a no-op,
// otherwise write_config drops it before build_rskey_phy_tlv runs.
for c in [
{
let mut c = empty_config_input();
c.raw_curves_mask = Some(0x08);
c
},
{
let mut c = empty_config_input();
c.led_order = Some(1);
c
},
{
let mut c = empty_config_input();
c.enabled_usb_itf = Some(0x05);
c
},
{
let mut c = empty_config_input();
c.led_num = Some(3);
c
},
] {
assert!(!is_empty_config_input(&c));
}
}
#[test]
fn test_build_rskey_phy_tlv_rejects_overlong_product_name() {
let mut c = empty_config_input();
c.product_name = Some("x".repeat(32)); // 32 + NUL = 33, the firmware max
assert!(build_rskey_phy_tlv(&c).is_ok());
c.product_name = Some("x".repeat(33)); // 33 + NUL = 34, over the limit
assert!(build_rskey_phy_tlv(&c).is_err());
}
#[test]
fn test_build_rskey_phy_tlv_emits_rskey_only_tags() {
let mut c = empty_config_input();
c.led_num = Some(3);
c.led_order = Some(1);
let tlv = build_rskey_phy_tlv(&c).unwrap();
// tag 0x0E len 0x01 val 0x03, and tag 0x0D len 0x01 val 0x01
assert!(tlv.windows(3).any(|w| w == [0x0E, 0x01, 0x03]));
assert!(tlv.windows(3).any(|w| w == [0x0D, 0x01, 0x01]));
}
}
+21 -7
View File
@@ -1432,15 +1432,16 @@ impl FidoOperations for HidTransport {
Ok(())
}
/// Read physical configuration from an RS-Key via CTAPHID 0x41 CONFIG_READ.
/// Read a device-config record from an RS-Key via CTAPHID 0x41 CONFIG_READ.
///
/// Sends `{1: 0x0D, 2: {1: target}}` CBOR payload to the RS-Key vendor
/// command handler inside a CTAPHID_CBOR message with the vendor sub-command
/// prefix. Returns raw TLV bytes for the requested target.
/// Ungated — no PIN needed.
/// command handler inside a CTAPHID_CBOR message. The firmware answers with
/// a CBOR map `{1: blob}`; this unwraps key 1 and returns the raw record
/// bytes. Ungated — no PIN needed.
///
/// Targets: `RSKEY_CFG_TARGET_DEV_CONF` (0x00), `RSKEY_CFG_TARGET_PHY` (0x01),
/// `RSKEY_CFG_TARGET_LED` (0x02).
/// Targets: `RSKEY_CFG_TARGET_PHY` (0x01) and `RSKEY_CFG_TARGET_LED` (0x02).
/// `DEV_CONF` (0x00) is write-only over FIDO — the firmware rejects it here
/// (readable only via the CCID Management applet), so this returns an error.
fn rs_key_config_read(&self, target: u8) -> Result<Vec<u8>, PFError> {
let mut params = BTreeMap::new();
params.insert(Value::Integer(1), Value::Integer(RSKEY_CONFIG_READ as i128));
@@ -1453,7 +1454,20 @@ impl FidoOperations for HidTransport {
let mut full_payload = vec![RSKEY_CTAPHID_VENDOR_CMD];
full_payload.extend(inner);
self.send_cbor(CTAPHID_CBOR, &full_payload)
let resp = self.send_cbor(CTAPHID_CBOR, &full_payload)?;
// Response is CBOR `{1: blob(bstr)}` — unwrap key 1 to the raw record.
match from_slice::<Value>(&resp) {
Ok(Value::Map(m)) => match m.get(&Value::Integer(1)) {
Some(Value::Bytes(b)) => Ok(b.clone()),
_ => Err(PFError::Device(
"CONFIG_READ response missing blob (key 1)".into(),
)),
},
_ => Err(PFError::Device(
"CONFIG_READ response is not a CBOR map".into(),
)),
}
}
/// Write physical configuration to an RS-Key via CTAPHID 0x41 CONFIG_WRITE.
+10 -4
View File
@@ -70,8 +70,8 @@ pub fn read_device_details() -> Result<FullDeviceStatus, PFError> {
} else {
fido.config.pid
},
led_gpio: rescue.config.led_gpio,
led_brightness: rescue.config.led_brightness,
led_gpio: rescue.config.led_gpio.or(fido.config.led_gpio),
led_brightness: rescue.config.led_brightness.or(fido.config.led_brightness),
led_dimmable: rescue.config.led_dimmable,
power_cycle_on_reset: rescue.config.power_cycle_on_reset,
led_steady: rescue.config.led_steady,
@@ -83,8 +83,14 @@ pub fn read_device_details() -> Result<FullDeviceStatus, PFError> {
None
}
}),
product_name: rescue.config.product_name,
touch_timeout: rescue.config.touch_timeout,
// Prefer the phy record's product override; fall back to the
// FIDO transport's USB product string when it has none.
product_name: if !rescue.config.product_name.is_empty() {
rescue.config.product_name
} else {
fido.config.product_name
},
touch_timeout: rescue.config.touch_timeout.or(fido.config.touch_timeout),
raw_curves_mask: rescue.config.raw_curves_mask,
led_order: rescue.config.led_order,
enabled_usb_itf: rescue.config.enabled_usb_itf,
+2 -2
View File
@@ -279,8 +279,8 @@ pub enum PhyTag {
/// Touch presence timeout.
///
/// Data format: `[TIMEOUT_MS_LSB, TIMEOUT_MS_MSB]` (2 bytes).
/// Timeout in milliseconds for user presence verification.
/// Data format: `[TIMEOUT_SECONDS]` (1 byte); `0`/absent = firmware default.
/// How long the device waits for a user touch before giving up.
PresenceTimeout = 0x08,
/// USB product string.
+17 -13
View File
@@ -327,17 +327,17 @@ impl RescueOperations for PcscTransport {
}
PhyTag::LedGpio => {
if !field_data.is_empty() {
config.led_gpio = field_data[0];
config.led_gpio = Some(field_data[0]);
}
}
PhyTag::LedBrightness => {
if !field_data.is_empty() {
config.led_brightness = field_data[0];
config.led_brightness = Some(field_data[0]);
}
}
PhyTag::PresenceTimeout => {
if !field_data.is_empty() {
config.touch_timeout = field_data[0];
config.touch_timeout = Some(field_data[0]);
}
}
PhyTag::UsbProduct => {
@@ -553,6 +553,14 @@ impl RescueOperations for PcscTransport {
tlv.push(val | UsbInterfaces::CCID.bits());
}
// LED count (Tag 0x0E) — RS-Key extension; the rescue write is full-replace,
// so emit it here too or a CCID write silently drops the configured count.
if let Some(val) = config.led_num {
tlv.push(PhyTag::LedNum as u8);
tlv.push(0x01);
tlv.push(val);
}
// 2. Connect and Send
if tlv.is_empty() {
log::warn!("No configuration changes to apply");
@@ -689,20 +697,16 @@ impl RescueOperations for PcscTransport {
let mut rx_buf = [0; 256];
let rx = self.transmit(&apdu, &mut rx_buf)?;
if !rx.ends_with(&SW_SUCCESS) || rx.len() < 11 {
if !rx.ends_with(&SW_SUCCESS) {
return Err(PFError::Device("Failed to read LED config".into()));
}
// The applet returns the raw `EF_LED_CONF` block (17 bytes on current
// firmware); `parse_led_block` reads colour/brightness at the right
// stride instead of assuming the legacy 9-byte layout.
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]);
}
let (steady, statuses) = crate::hal::common::parse_led_block(data)
.ok_or_else(|| PFError::Device("LED config response too short".into()))?;
log::info!("LED config: steady={}, statuses={:?}", steady, statuses);
Ok(LedStatusConfig { steady, statuses })
+20
View File
@@ -217,6 +217,26 @@ impl HidTransport {
})
}
/// Cheap, non-intrusive presence fingerprint of the attached FIDO HID device.
///
/// Returns `vid:pid:serial` (serial may be empty) for the first device with
/// the FIDO usage page, or `None` when none is present. It only *enumerates*
/// USB descriptors — it does not open the device or run `CTAPHID_INIT`, so it
/// is safe to poll on a timer even while another handle holds the device open.
/// A change in the returned value signals a plug / unplug / swap.
pub fn fingerprint() -> Option<String> {
let api = hidapi::HidApi::new().ok()?;
let info = api
.device_list()
.find(|d| d.usage_page() == HID_USAGE_PAGE_FIDO)?;
Some(format!(
"{:04x}:{:04x}:{}",
info.vendor_id(),
info.product_id(),
info.serial_number().unwrap_or("")
))
}
/// Negotiate a CTAPHID Channel ID via CTAPHID_INIT.
///
/// Sends an INIT command to the broadcast CID (`0xFFFFFFFF`) with a random
+11 -5
View File
@@ -34,11 +34,17 @@ pub struct AppConfig {
pub vid: String,
pub pid: String,
pub product_name: String,
/// GPIO pin the status LED is connected to.
pub led_gpio: u8,
pub led_brightness: u8,
/// Touch-button press timeout in seconds.
pub touch_timeout: u8,
/// GPIO pin the status LED is connected to. `None` = no phy override, i.e.
/// the firmware's build-time default (which the device doesn't report back).
#[serde(skip_serializing_if = "Option::is_none")]
pub led_gpio: Option<u8>,
/// Global LED brightness cap. `None` = no phy override (firmware default).
#[serde(skip_serializing_if = "Option::is_none")]
pub led_brightness: Option<u8>,
/// Touch-button press timeout in seconds. `None` = no phy override; the
/// firmware then uses its built-in default (30 s), and `0` means the same.
#[serde(skip_serializing_if = "Option::is_none")]
pub touch_timeout: Option<u8>,
/// LED driver type identifier (e.g. PWM direct vs external driver).
#[serde(skip_serializing_if = "Option::is_none")]
pub led_driver: Option<u8>,
+4 -1
View File
@@ -123,7 +123,10 @@ impl ApplicationRoot {
focus_handle: cx.focus_handle(),
};
device.update(cx, |repo, cx| repo.refresh(cx));
device.update(cx, |repo, cx| {
repo.refresh(cx);
repo.start_hotplug_watch(cx);
});
this
}
+63
View File
@@ -18,6 +18,11 @@ use crate::hal::firmwares::AnyFirmware;
use crate::hal::io;
use crate::hal::types;
use gpui::*;
use std::time::Duration;
/// How often the hot-plug watcher samples device presence. Only a *change*
/// triggers a refresh, so this is a detection-latency knob, not a poll cost.
const HOTPLUG_POLL_MS: u64 = 1000;
pub use crate::hal::rescue::constants::{
LedColor, LedStatus, USB_CAP_FIDO2, USB_CAP_OATH, USB_CAP_OPENPGP, USB_CAP_OTP, USB_CAP_PIV,
@@ -58,6 +63,8 @@ pub struct DeviceRepo {
pub error: Option<String>,
pub loading: bool,
pub device_changed: bool,
/// Handle to the hot-plug watcher task; dropped (cancelled) with the repo.
hotplug_watch: Option<Task<()>>,
}
impl DeviceRepo {
@@ -71,6 +78,7 @@ impl DeviceRepo {
error: None,
loading: false,
device_changed: false,
hotplug_watch: None,
}
}
@@ -177,6 +185,13 @@ impl DeviceRepo {
crate::hal::transport::fido::HidTransport::open().is_ok()
}
/// Cheap, non-intrusive presence fingerprint of the attached FIDO device
/// (`vid:pid:serial`, or `None` when absent). Enumerates only — does not
/// open the device — so it is safe to poll from the hot-plug watcher.
pub fn device_fingerprint_blocking() -> Option<String> {
crate::hal::transport::fido::HidTransport::fingerprint()
}
// ── State mutation (called from ViewModel after background work) ───────
/// Push a freshly-read [`FreshDeviceState`] into the repo and emit
@@ -206,6 +221,54 @@ impl DeviceRepo {
// ── Polling cycle ──────────────────────────────────────────────────────
/// Start the hot-plug watcher: a background timer that samples the device
/// fingerprint and, whenever it changes (plug / unplug / swap), triggers a
/// [`refresh`](Self::refresh) so every screen reflects the current key with
/// no manual Refresh. Idempotent — a second call is a no-op. The task is
/// owned by the repo and cancelled when it is dropped.
pub fn start_hotplug_watch(&mut self, cx: &mut Context<Self>) {
if self.hotplug_watch.is_some() {
return;
}
let weak = cx.entity().downgrade();
self.hotplug_watch = Some(cx.spawn(async move |_, cx| {
// Seed with the fingerprint the initial refresh already reflects so
// the first tick doesn't re-read an already-loaded device.
let mut last = cx
.background_executor()
.spawn(async { Self::device_fingerprint_blocking() })
.await;
loop {
cx.background_executor()
.timer(Duration::from_millis(HOTPLUG_POLL_MS))
.await;
let current = cx
.background_executor()
.spawn(async { Self::device_fingerprint_blocking() })
.await;
if current == last {
continue;
}
// Re-read on the main thread. Skip while a refresh/write is in
// flight and retry next tick (don't commit `last`, or we'd drop
// the change). Break when the repo — and thus the app — is gone.
let refreshed = weak.update(cx, |repo, cx| {
if repo.loading {
false
} else {
repo.refresh(cx);
true
}
});
match refreshed {
Ok(true) => last = current,
Ok(false) => {}
Err(_) => break,
}
}
}));
}
/// Initiate a device-details refresh (async, emits [`DeviceEvent::Updated`] on completion).
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.loading {
+2 -66
View File
@@ -224,71 +224,6 @@ impl ConfigViewModel {
.child(content)
}
fn render_curves_card(&mut self, cx: &mut Context<Self>, is_fido: bool) -> impl IntoElement {
let theme = cx.theme();
let mut rows = v_flex().gap_4();
let curves = [
("curve-p256", "P-256 (secp256r1)", self.curve_p256),
("curve-p384", "P-384 (secp384r1)", self.curve_p384),
("curve-p521", "P-521 (secp521r1)", self.curve_p521),
("curve-k1", "secp256k1 (Bitcoin)", self.curve_secp256k1),
("curve-bp256", "Brainpool 256r1", self.curve_bp256),
("curve-bp384", "Brainpool 384r1", self.curve_bp384),
("curve-bp512", "Brainpool 512r1", self.curve_bp512),
("curve-ed25519", "Ed25519", self.curve_ed25519),
("curve-ed448", "Ed448", self.curve_ed448),
("curve-x25519", "X25519", self.curve_x25519),
("curve-x448", "X448", self.curve_x448),
];
for (id, label, checked) in curves {
let toggle_listener = cx.listener(move |this, checked, _, cx| {
match id {
"curve-p256" => this.curve_p256 = *checked,
"curve-p384" => this.curve_p384 = *checked,
"curve-p521" => this.curve_p521 = *checked,
"curve-k1" => this.curve_secp256k1 = *checked,
"curve-bp256" => this.curve_bp256 = *checked,
"curve-bp384" => this.curve_bp384 = *checked,
"curve-bp512" => this.curve_bp512 = *checked,
"curve-ed25519" => this.curve_ed25519 = *checked,
"curve-ed448" => this.curve_ed448 = *checked,
"curve-x25519" => this.curve_x25519 = *checked,
"curve-x448" => this.curve_x448 = *checked,
_ => {}
}
cx.notify();
});
rows = rows.child(
h_flex()
.items_center()
.justify_between()
.child(
v_flex().gap_0p5().child(label).child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child("Cryptographic curve"),
),
)
.child(
Switch::new(id)
.checked(checked)
.disabled(is_fido)
.on_click(toggle_listener),
),
);
}
Card::new()
.title("Supported Curves")
.description("Enable or disable cryptographic curves for RS-Key")
.icon(Icon::default().path("icons/shield.svg"))
.child(rows)
}
fn render_rskey_led_card(&mut self, cx: &mut Context<Self>, is_fido: bool) -> impl IntoElement {
let theme = cx.theme();
let mut rows = v_flex().gap_4();
@@ -652,8 +587,9 @@ impl Render for ConfigViewModel {
.child(options_card);
if is_rskey {
// No curves card: RS-Key's firmware ignores the phy ENABLED_CURVES
// tag (curve support is compile-time), so exposing it would only mislead.
inner = inner
.child(self.render_curves_card(cx, false))
.child(self.render_rskey_led_card(cx, false))
.child(self.render_rskey_apps_card(cx, false))
.child(self.render_rskey_usb_itf_card(cx, false));
+113 -55
View File
@@ -13,6 +13,11 @@ use gpui_component::input::InputState;
use gpui_component::select::{SelectItem, SelectState};
use gpui_component::slider::SliderState;
/// Slider position shown for LED brightness when the device has no phy override.
/// Purely cosmetic: an unmoved slider is treated as "no override" on save, so this
/// value is never written unless the user actually drags the slider.
const DEFAULT_BRIGHTNESS: u8 = 8;
/// Known USB vendor/product identity presets for various security keys.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsbIdentityPreset {
@@ -260,13 +265,20 @@ impl ConfigViewModel {
let current_product_name: SharedString = config
.map(|c| c.product_name.clone().into())
.unwrap_or_else(|| "My Key".into());
// `None` (no phy override) → blank input, so it reads as "firmware default"
// rather than a bogus "0" and isn't written back on save.
let current_led_gpio: SharedString = config
.map(|c| c.led_gpio.to_string().into())
.unwrap_or_else(|| "25".into());
.and_then(|c| c.led_gpio)
.map(|g| g.to_string().into())
.unwrap_or_default();
let current_touch_timeout: SharedString = config
.map(|c| c.touch_timeout.to_string().into())
.unwrap_or_else(|| "10".into());
let current_brightness = config.map(|c| c.led_brightness as f32).unwrap_or(8.0);
.and_then(|c| c.touch_timeout)
.map(|t| t.to_string().into())
.unwrap_or_default();
let current_brightness = config
.and_then(|c| c.led_brightness)
.map(|b| b as f32)
.unwrap_or(DEFAULT_BRIGHTNESS as f32);
let led_dimmable = config.map(|c| c.led_dimmable).unwrap_or(true);
let led_steady = config.map(|c| c.led_steady).unwrap_or(false);
@@ -337,8 +349,11 @@ impl ConfigViewModel {
let product_name_input =
cx.new(|cx| InputState::new(window, cx).default_value(current_product_name.clone()));
let led_gpio_input =
cx.new(|cx| InputState::new(window, cx).default_value(current_led_gpio.clone()));
let led_gpio_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Firmware default")
.default_value(current_led_gpio.clone())
});
let initial_driver_idx = LedDriverType::all()
.iter()
@@ -384,8 +399,11 @@ impl ConfigViewModel {
.default_value(current_brightness)
});
let touch_timeout_input =
cx.new(|cx| InputState::new(window, cx).default_value(current_touch_timeout.clone()));
let touch_timeout_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Firmware default (30s)")
.default_value(current_touch_timeout.clone())
});
Self {
device,
@@ -647,6 +665,7 @@ impl ConfigViewModel {
let raw_curves_mask = status.config.raw_curves_mask;
let led_order = status.config.led_order;
let method = status.method.clone();
let is_rskey = status.firmware_type == crate::ui::models::device::FirmwareType::RSKey;
let mut has_changes = false;
@@ -665,40 +684,65 @@ impl ConfigViewModel {
has_changes = true;
}
let mut final_led_gpio = current_led_gpio;
// LED GPIO: an empty input means "no phy override" (firmware default) and
// is not written; a value is written only when the user typed one.
let led_gpio_str = self.led_gpio_input.read(cx).text().to_string();
if let Ok(parsed_gpio) = led_gpio_str.parse::<u8>() {
if parsed_gpio != current_led_gpio {
has_changes = true;
}
final_led_gpio = parsed_gpio;
}
let mut final_led_driver = current_led_driver;
let driver_idx = self.led_driver_select.read(cx).selected_index(cx);
if let Some(idx) = driver_idx
&& let Some(driver) = LedDriverType::all().get(idx.row)
{
let driver_value = driver.value();
let current_driver_value = current_led_driver.unwrap_or(1);
if driver_value != current_driver_value {
has_changes = true;
}
final_led_driver = Some(driver_value);
}
let brightness = self.led_brightness_slider.read(cx).value().start() as u8;
if brightness != current_led_brightness {
let trimmed_gpio = led_gpio_str.trim();
let final_led_gpio = if trimmed_gpio.is_empty() {
None
} else {
trimmed_gpio.parse::<u8>().ok().or(current_led_gpio)
};
if final_led_gpio != current_led_gpio {
has_changes = true;
}
let mut final_touch_timeout = current_touch_timeout;
// LED driver: preserve the device's value (None = firmware default) unless
// the user picks a different entry than the one it booted with — an
// untouched select must not clobber a virgin phy with a bogus driver.
let init_driver_idx = LedDriverType::all()
.iter()
.position(|d| Some(d.value()) == current_led_driver)
.unwrap_or(0);
let sel_driver_idx = self
.led_driver_select
.read(cx)
.selected_index(cx)
.map(|p| p.row)
.unwrap_or(init_driver_idx);
let final_led_driver = if sel_driver_idx != init_driver_idx {
LedDriverType::all().get(sel_driver_idx).map(|d| d.value())
} else {
current_led_driver
};
if final_led_driver != current_led_driver {
has_changes = true;
}
// LED brightness: an unmoved slider preserves the device's value
// (None = firmware default) instead of writing its placeholder position.
let init_brightness = current_led_brightness.unwrap_or(DEFAULT_BRIGHTNESS);
let slider_brightness = self.led_brightness_slider.read(cx).value().start() as u8;
let final_led_brightness = if slider_brightness != init_brightness {
Some(slider_brightness)
} else {
current_led_brightness
};
if final_led_brightness != current_led_brightness {
has_changes = true;
}
// Touch timeout: empty input = "firmware default" (no override written).
// `0` firmware-side also means the 30 s default, so an empty field is honest.
let touch_timeout_str = self.touch_timeout_input.read(cx).text().to_string();
if let Ok(val) = touch_timeout_str.parse::<u8>() {
if val != current_touch_timeout {
has_changes = true;
}
final_touch_timeout = val;
let trimmed_tt = touch_timeout_str.trim();
let final_touch_timeout = if trimmed_tt.is_empty() {
None
} else {
trimmed_tt.parse::<u8>().ok().or(current_touch_timeout)
};
if final_touch_timeout != current_touch_timeout {
has_changes = true;
}
if (self.led_dimmable != current_led_dimmable)
@@ -708,9 +752,24 @@ impl ConfigViewModel {
has_changes = true;
}
let new_curves_mask = Self::curves_mask_from_toggles(self);
let has_curve_changes = Some(new_curves_mask) != raw_curves_mask
|| (raw_curves_mask.is_none() && new_curves_mask != 0);
// RS-Key ignores the phy ENABLED_CURVES tag (its curves card is hidden),
// so preserve the device's mask rather than rebuilding it from the hidden
// toggles — otherwise Apply Changes would write a meaningless curves tag.
let (has_curve_changes, built_curves_mask) = if is_rskey {
(false, raw_curves_mask)
} else {
let new_curves_mask = Self::curves_mask_from_toggles(self);
let changed = Some(new_curves_mask) != raw_curves_mask
|| (raw_curves_mask.is_none() && new_curves_mask != 0);
(
changed,
if changed {
Some(new_curves_mask)
} else {
raw_curves_mask
},
)
};
if has_curve_changes {
has_changes = true;
}
@@ -726,18 +785,13 @@ impl ConfigViewModel {
return;
}
let built_curves_mask = if has_curve_changes {
Some(new_curves_mask)
} else {
raw_curves_mask
};
let changes = AppConfigInput {
vid: Some(vid),
pid: Some(pid),
product_name: Some(product_name),
led_gpio: Some(final_led_gpio),
led_brightness: Some(brightness),
touch_timeout: Some(final_touch_timeout),
led_gpio: final_led_gpio,
led_brightness: final_led_brightness,
touch_timeout: final_touch_timeout,
led_driver: final_led_driver,
led_dimmable: Some(self.led_dimmable),
power_cycle_on_reset: Some(self.power_cycle),
@@ -750,7 +804,6 @@ impl ConfigViewModel {
};
if method == DeviceMethod::Fido {
let is_rskey = status.firmware_type == crate::ui::models::device::FirmwareType::RSKey;
if Self::status_supports_legacy_fido_config(status) || is_rskey {
self.open_pin_dialog(changes, window, cx);
} else {
@@ -835,18 +888,23 @@ impl ConfigViewModel {
.map(|c| c.product_name.clone())
.unwrap_or_else(|| "My Key".into());
let new_gpio = config
.map(|c| c.led_gpio.to_string())
.unwrap_or_else(|| "25".into());
.and_then(|c| c.led_gpio)
.map(|g| g.to_string())
.unwrap_or_default();
let new_timeout = config
.map(|c| c.touch_timeout.to_string())
.unwrap_or_else(|| "10".into());
.and_then(|c| c.touch_timeout)
.map(|t| t.to_string())
.unwrap_or_default();
self.led_dimmable = config.map(|c| c.led_dimmable).unwrap_or(true);
self.led_steady = config.map(|c| c.led_steady).unwrap_or(false);
self.power_cycle = config.map(|c| c.power_cycle_on_reset).unwrap_or(false);
Self::sync_curve_toggles(self, config);
let brightness = config.map(|c| c.led_brightness as f32).unwrap_or(8.0);
let brightness = config
.and_then(|c| c.led_brightness)
.map(|b| b as f32)
.unwrap_or(DEFAULT_BRIGHTNESS as f32);
let new_driver_val = config.and_then(|c| c.led_driver).unwrap_or(1);
+18 -3
View File
@@ -292,7 +292,12 @@ impl HomeViewModel {
.text_color(theme.muted_foreground)
.child("LED GPIO Pin"),
)
.child(format!("GPIO {}", config.led_gpio)),
.child(
config
.led_gpio
.map(|g| format!("GPIO {}", g))
.unwrap_or_else(|| "Firmware default".into()),
),
)
.child(
h_flex()
@@ -302,7 +307,12 @@ impl HomeViewModel {
.text_color(theme.muted_foreground)
.child("LED Brightness"),
)
.child(config.led_brightness.to_string()),
.child(
config
.led_brightness
.map(|b| b.to_string())
.unwrap_or_else(|| "Firmware default".into()),
),
)
.child(
h_flex()
@@ -312,7 +322,12 @@ impl HomeViewModel {
.text_color(theme.muted_foreground)
.child("Presence Touch Timeout"),
)
.child(format!("{}s", config.touch_timeout)),
.child(
config
.touch_timeout
.map(|t| format!("{}s", t))
.unwrap_or_else(|| "Firmware default".into()),
),
)
.child(
h_flex()