From 4ce855cb7f90680ab164e491d7ab1e1968ca6835 Mon Sep 17 00:00:00 2001 From: Maxim Muravev Date: Sat, 25 Jul 2026 13:54:58 +0300 Subject: [PATCH 1/7] chore(deps): add bip39 for RS-Key seed backup The RS-Key FIDO seed-backup export renders the master seed as a 24-word BIP-39 mnemonic; add the `bip39` crate for the word-list codec. --- Cargo.lock | 39 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + 2 files changed, 40 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ea30bf0..7ca05b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -531,6 +531,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -552,6 +563,15 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "hex-conservative", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -2621,6 +2641,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + [[package]] name = "hexf-parse" version = "0.2.1" @@ -4232,6 +4261,7 @@ dependencies = [ "aes 0.9.1", "anyhow", "base64", + "bip39", "bitflags 2.13.1", "byteorder", "cbc 0.2.1", @@ -6520,6 +6550,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index a8b659c..6863c84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ base64 = "0.22" # For PEM encoding of DER certificates ring = "0.17" # For signing fido2 messages with pin token aes = "0.9" cbc = "0.2" +bip39 = "2" # For rendering the FIDO seed backup as a 24-word mnemonic # For Application UI: gpui = { version = "0.2.2", features = [] } From de154183c9f3c449286280ccd7fbde44a053fd48 Mon Sep 17 00:00:00 2001 From: Maxim Muravev Date: Sat, 25 Jul 2026 13:54:59 +0300 Subject: [PATCH 2/7] feat(hal): RS-Key CCID applets + FIDO vendor management Host-side protocol layer for the full RS-Key applet and management surface, each codec written against the current firmware's wire contract: - CCID foundation: ISO-7816 APDU + BER-TLV codecs (`apdu/`), a persistent `CcidSession` with 61xx/6Cxx assembly and CLA-chaining (`transport/ccid.rs`), and a firmware-agnostic `AppletProfile` (`firmwares/applets.rs`) so support gating is per-firmware, not baked in. - Applet clients (`applets/`): OATH (YKOATH), OTP (YubiKey slots), PIV, OpenPGP. OATH LIST / CALCULATE ALL page via SEND REMAINING (0xA5), not ISO GET RESPONSE; PIV CHANGE REFERENCE sends the 8-byte-padded block and supports the ykman `--protect` PIN-fetched management key (`MgmAuth`); OTP pads the HMAC challenge to a 64-byte frame and threads a slot access code through delete/swap. - FIDO vendor management (`fido/{audit,backup}.rs`, `offboard.rs`): the tamper-evident audit journal (read / verify / enable-disable via `VENDOR_AUDIT_CONFIG`), seed backup, soft-lock, enterprise attestation and offboard, over the CTAPHID 0x41 channel. - Config over FIDO/rescue: the runtime manufacturer string (phy tag 0x0F), the boot-effective LED pin/driver + touch timeout the device reports in CONFIG_READ key 2, a single write orchestrator (`write_all_config`), and treating an absent USB_ENABLED mask as all-enabled. --- src/hal/apdu/mod.rs | 180 ++++++ src/hal/apdu/tlv.rs | 153 ++++++ src/hal/applets/mod.rs | 74 +++ src/hal/applets/oath.rs | 654 ++++++++++++++++++++++ src/hal/applets/openpgp.rs | 462 ++++++++++++++++ src/hal/applets/otp.rs | 594 ++++++++++++++++++++ src/hal/applets/piv.rs | 1007 ++++++++++++++++++++++++++++++++++ src/hal/fido/audit.rs | 260 +++++++++ src/hal/fido/backup.rs | 135 +++++ src/hal/fido/constants.rs | 38 ++ src/hal/fido/mod.rs | 550 ++++++++++++++++++- src/hal/fido/ops.rs | 149 ++++- src/hal/firmwares/applets.rs | 110 ++++ src/hal/firmwares/mod.rs | 1 + src/hal/io.rs | 586 +++++++++++++++++++- src/hal/mod.rs | 3 + src/hal/offboard.rs | 127 +++++ src/hal/rescue/constants.rs | 3 + src/hal/rescue/ops.rs | 56 +- src/hal/transport/ccid.rs | 139 +++++ src/hal/transport/fido.rs | 22 + src/hal/transport/mod.rs | 2 + src/hal/types.rs | 31 ++ 23 files changed, 5305 insertions(+), 31 deletions(-) create mode 100644 src/hal/apdu/mod.rs create mode 100644 src/hal/apdu/tlv.rs create mode 100644 src/hal/applets/mod.rs create mode 100644 src/hal/applets/oath.rs create mode 100644 src/hal/applets/openpgp.rs create mode 100644 src/hal/applets/otp.rs create mode 100644 src/hal/applets/piv.rs create mode 100644 src/hal/fido/audit.rs create mode 100644 src/hal/fido/backup.rs create mode 100644 src/hal/firmwares/applets.rs create mode 100644 src/hal/offboard.rs create mode 100644 src/hal/transport/ccid.rs diff --git a/src/hal/apdu/mod.rs b/src/hal/apdu/mod.rs new file mode 100644 index 0000000..fe5aa79 --- /dev/null +++ b/src/hal/apdu/mod.rs @@ -0,0 +1,180 @@ +//! ISO 7816-4 APDU encoding and status-word decoding. +//! +//! Firmware-agnostic: every CCID applet (OATH, PIV, OpenPGP, OTP) speaks the +//! same command/response APDU framing over PC/SC. This is the shared base the +//! `hal::applets::*` modules build on, alongside the BER-TLV codec in [`tlv`]. + +// Staged applet foundation: some helpers (chaining class, retry decoding) are +// consumed by later applet stages rather than the OATH screen alone. +#![allow(dead_code)] + +use crate::error::PFError; + +pub mod tlv; + +/// GET RESPONSE — pulls the next chunk after a `61xx` status (`00 C0 00 00 Le`). +pub const INS_GET_RESPONSE: u8 = 0xC0; +/// SELECT by DF name (`00 A4 04 00`). +pub const INS_SELECT: u8 = 0xA4; +/// YKOATH SEND REMAINING (`00 A5 00 00 Le`) — continues a `61xx` page. OATH +/// paginates LIST / CALCULATE ALL with this, not ISO GET RESPONSE (`0xC0`), +/// which it rejects with `6D00` (dropping the pending page). +pub const INS_SEND_REMAINING: u8 = 0xA5; +/// ISO class byte for the applets we speak (all use `0x00`). +pub const CLA_ISO: u8 = 0x00; +/// Command-chaining class bit — set on every fragment but the last. +pub const CLA_CHAIN: u8 = 0x10; + +/// A command APDU. `le` requests a response length (`Some(0)` = the ISO short +/// form "send up to 256 bytes"); `None` is a pure write with no `Le`. +#[derive(Debug, Clone)] +pub struct Apdu { + pub cla: u8, + pub ins: u8, + pub p1: u8, + pub p2: u8, + pub data: Vec, + pub le: Option, +} + +impl Apdu { + /// A command that expects a response (`Le = 0` short form). + pub fn read(cla: u8, ins: u8, p1: u8, p2: u8, data: &[u8]) -> Self { + Self { cla, ins, p1, p2, data: data.to_vec(), le: Some(0) } + } + + /// A pure write (no `Le`), for commands that answer with only a status word. + pub fn write(cla: u8, ins: u8, p1: u8, p2: u8, data: &[u8]) -> Self { + Self { cla, ins, p1, p2, data: data.to_vec(), le: None } + } + + /// Encode to the wire. Short-form Lc/Le when the body is ≤255 bytes, + /// extended-form (3-byte Lc, 2-byte Le) when longer. + pub fn encode(&self) -> Vec { + let mut out = vec![self.cla, self.ins, self.p1, self.p2]; + let n = self.data.len(); + if n == 0 { + if let Some(le) = self.le { + out.push((le & 0xFF) as u8); // 0 → 256 (ISO short Le) + } + } else if n <= 255 && self.le.map(|le| le <= 256).unwrap_or(true) { + out.push(n as u8); + out.extend_from_slice(&self.data); + if let Some(le) = self.le { + out.push((le & 0xFF) as u8); + } + } else { + // Extended: 3-byte Lc, and a 2-byte Le if requested. + out.push(0); + out.extend_from_slice(&(n as u16).to_be_bytes()); + out.extend_from_slice(&self.data); + if let Some(le) = self.le { + out.extend_from_slice(&le.to_be_bytes()); + } + } + out + } +} + +/// A two-byte ISO status word (SW1 SW2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StatusWord(pub u16); + +impl StatusWord { + pub const OK: u16 = 0x9000; + + pub fn is_ok(self) -> bool { + self.0 == Self::OK + } + + /// `61xx` → `xx` more bytes available via GET RESPONSE (`0` means 256). + pub fn more_data(self) -> Option { + ((self.0 & 0xFF00) == 0x6100).then_some((self.0 & 0xFF) as u8) + } + + /// `6Cxx` → resend the command with `Le = xx`. + pub fn wrong_le(self) -> Option { + ((self.0 & 0xFF00) == 0x6C00).then_some((self.0 & 0xFF) as u8) + } + + /// `63Cx` → `x` verification attempts remaining (wrong PIN/password). + pub fn retries_left(self) -> Option { + ((self.0 & 0xFFF0) == 0x63C0).then_some((self.0 & 0x0F) as u8) + } + + /// Map a non-`9000` status to a typed, user-facing error. + pub fn to_error(self) -> PFError { + let msg = match self.0 { + 0x6581 => "Storage failure — not enough memory on the device".to_string(), + 0x6982 => "Security status not satisfied — unlock or verify the PIN first".to_string(), + 0x6983 => "Authentication method blocked".to_string(), + 0x6A80 => "Incorrect parameters in the command data".to_string(), + 0x6A81 => "Function not supported by this firmware".to_string(), + 0x6A82 => "Requested object not found".to_string(), + 0x6A83 => "Record not found".to_string(), + 0x6A84 => "Not enough memory on the device".to_string(), + 0x6A88 => "Reference data not found — no reset code set, or unknown object".to_string(), + 0x6985 => "Conditions of use not satisfied".to_string(), + 0x6700 => "Wrong length".to_string(), + 0x6B00 => "Wrong parameters P1-P2".to_string(), + 0x6D00 => "Instruction not supported".to_string(), + 0x6E00 => "Applet class not supported".to_string(), + sw if (sw & 0xFFF0) == 0x63C0 => { + format!("Verification failed — {} attempt(s) left", sw & 0x0F) + } + other => format!("Card returned status 0x{other:04X}"), + }; + PFError::Device(msg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_case2_no_data_with_le() { + // LIST: header + single Le byte (0 = 256). + assert_eq!(Apdu::read(0x00, 0xA1, 0, 0, &[]).encode(), vec![0x00, 0xA1, 0, 0, 0x00]); + } + + #[test] + fn encodes_case3_write_no_le() { + assert_eq!( + Apdu::write(0x00, 0x02, 0, 0, &[0x71, 0x01, 0xAA]).encode(), + vec![0x00, 0x02, 0, 0, 0x03, 0x71, 0x01, 0xAA] + ); + } + + #[test] + fn encodes_case4_data_and_le() { + assert_eq!( + Apdu::read(0x00, 0xA2, 0, 1, &[0x71, 0x01, 0xAA]).encode(), + vec![0x00, 0xA2, 0, 1, 0x03, 0x71, 0x01, 0xAA, 0x00] + ); + } + + #[test] + fn encodes_extended_lc_when_body_over_255() { + let body = vec![0x5A; 300]; + let enc = Apdu::write(0x00, 0xDB, 0x3F, 0xFF, &body).encode(); + assert_eq!(&enc[..7], &[0x00, 0xDB, 0x3F, 0xFF, 0x00, 0x01, 0x2C]); // Lc = 0x012C = 300 + assert_eq!(enc.len(), 4 + 3 + 300); + } + + #[test] + fn status_word_classification() { + assert!(StatusWord(0x9000).is_ok()); + assert_eq!(StatusWord(0x6110).more_data(), Some(0x10)); + assert_eq!(StatusWord(0x6C1D).wrong_le(), Some(0x1D)); + assert_eq!(StatusWord(0x63C2).retries_left(), Some(2)); + assert_eq!(StatusWord(0x9000).more_data(), None); + } + + #[test] + fn oath_continuation_ins_is_send_remaining() { + // OATH pages with SEND REMAINING (0xA5), not GET RESPONSE (0xC0). + assert_eq!(INS_SEND_REMAINING, 0xA5); + assert_ne!(INS_SEND_REMAINING, INS_GET_RESPONSE); + } +} diff --git a/src/hal/apdu/tlv.rs b/src/hal/apdu/tlv.rs new file mode 100644 index 0000000..c40ed89 --- /dev/null +++ b/src/hal/apdu/tlv.rs @@ -0,0 +1,153 @@ +//! Minimal BER-TLV reader/writer for applet data objects. +//! +//! Handles multi-byte tags (e.g. `0x7F49`, `0x5FC105`) and multi-byte lengths, +//! which the rescue module's hand-rolled 1-byte-length loops cannot parse. Tags +//! are carried as `u32` (their big-endian byte sequence), which covers every tag +//! the OATH/PIV/OpenPGP applets use. + +/// Iterator over the `(tag, value)` pairs in a BER-TLV byte string. +pub struct TlvIter<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> TlvIter<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } +} + +impl<'a> Iterator for TlvIter<'a> { + type Item = (u32, &'a [u8]); + + fn next(&mut self) -> Option { + let tag = read_tag(self.data, &mut self.pos)?; + let len = read_len(self.data, &mut self.pos)?; + let end = self.pos.checked_add(len)?; + if end > self.data.len() { + return None; + } + let value = &self.data[self.pos..end]; + self.pos = end; + Some((tag, value)) + } +} + +/// Read a BER tag starting at `*pos`, advancing it past the tag bytes. +pub fn read_tag(data: &[u8], pos: &mut usize) -> Option { + let first = *data.get(*pos)?; + *pos += 1; + let mut tag = first as u32; + // Multi-byte tag: low 5 bits all set, continuation while high bit set. + if first & 0x1F == 0x1F { + loop { + let b = *data.get(*pos)?; + *pos += 1; + tag = (tag << 8) | b as u32; + if b & 0x80 == 0 { + break; + } + } + } + Some(tag) +} + +/// Read a BER length starting at `*pos`, advancing it past the length bytes. +pub fn read_len(data: &[u8], pos: &mut usize) -> Option { + let first = *data.get(*pos)?; + *pos += 1; + if first & 0x80 == 0 { + return Some(first as usize); + } + let nbytes = (first & 0x7F) as usize; + if nbytes == 0 || nbytes > 4 { + return None; // indefinite form / oversized — not used by these applets + } + let mut len = 0usize; + for _ in 0..nbytes { + let b = *data.get(*pos)?; + *pos += 1; + len = (len << 8) | b as usize; + } + Some(len) +} + +/// First value carrying `tag` at the top level of `data`, if present. +pub fn find(data: &[u8], tag: u32) -> Option<&[u8]> { + TlvIter::new(data).find(|(t, _)| *t == tag).map(|(_, v)| v) +} + +/// Append a TLV (`tag` as its minimal big-endian bytes, BER length, value). +pub fn write(out: &mut Vec, tag: u32, value: &[u8]) { + // Tag: strip leading zero bytes, but keep at least one. + let tag_bytes = tag.to_be_bytes(); + let start = tag_bytes.iter().position(|&b| b != 0).unwrap_or(3); + out.extend_from_slice(&tag_bytes[start..]); + write_len(out, value.len()); + out.extend_from_slice(value); +} + +/// Append a BER length octet sequence. +pub fn write_len(out: &mut Vec, len: usize) { + if len < 0x80 { + out.push(len as u8); + } else if len <= 0xFF { + out.push(0x81); + out.push(len as u8); + } else if len <= 0xFFFF { + out.push(0x82); + out.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + out.push(0x83); + out.extend_from_slice(&(len as u32).to_be_bytes()[1..]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_single_byte_tags() { + let mut buf = Vec::new(); + write(&mut buf, 0x71, b"Example:alice"); + write(&mut buf, 0x73, &[0x21, 0x06, 0xDE, 0xAD]); + let items: Vec<_> = TlvIter::new(&buf).collect(); + assert_eq!(items.len(), 2); + assert_eq!(items[0], (0x71, b"Example:alice".as_slice())); + assert_eq!(items[1], (0x73, [0x21, 0x06, 0xDE, 0xAD].as_slice())); + } + + #[test] + fn parses_multi_byte_tag_and_len() { + // Tag 0x7F49, length 0x81 0x80 (128 bytes). + let mut buf = vec![0x7F, 0x49, 0x81, 0x80]; + buf.extend(std::iter::repeat_n(0xAB, 128)); + let (tag, val) = TlvIter::new(&buf).next().unwrap(); + assert_eq!(tag, 0x7F49); + assert_eq!(val.len(), 128); + } + + #[test] + fn write_encodes_extended_length() { + let mut buf = Vec::new(); + write(&mut buf, 0x5FC105, &vec![0u8; 300]); + assert_eq!(&buf[..6], &[0x5F, 0xC1, 0x05, 0x82, 0x01, 0x2C]); + } + + #[test] + fn find_returns_first_match() { + let mut buf = Vec::new(); + write(&mut buf, 0x71, b"one"); + write(&mut buf, 0x71, b"two"); + assert_eq!(find(&buf, 0x71), Some(b"one".as_slice())); + assert_eq!(find(&buf, 0x99), None); + } + + #[test] + fn truncated_value_yields_no_item() { + // Claims length 5 but only 2 bytes follow. + let buf = [0x71, 0x05, 0xAA, 0xBB]; + assert_eq!(TlvIter::new(&buf).next(), None); + } +} diff --git a/src/hal/applets/mod.rs b/src/hal/applets/mod.rs new file mode 100644 index 0000000..35ac143 --- /dev/null +++ b/src/hal/applets/mod.rs @@ -0,0 +1,74 @@ +//! Firmware-agnostic CCID applet clients (Yubico / ISO wire protocol). +//! +//! Each submodule speaks one applet's command set over a +//! [`CcidSession`](crate::hal::transport::ccid::CcidSession). The wire protocol +//! is identical across firmwares that emulate the Yubico applets, so only +//! *feature gating* differs — and that lives in +//! [`crate::hal::firmwares::applets`], keyed off the descriptors below. +//! +//! Adding a new applet screen means: a submodule here (the ops), a `*Features` +//! descriptor, a method on `AppletProfile`, and per-firmware answers — the UI +//! and transport layers do not change. + +pub mod oath; +pub mod openpgp; +pub mod otp; +pub mod piv; + +/// OATH applet features a firmware exposes, read by the Accounts screen to +/// show or disable controls. Absence of a whole applet is expressed one level +/// up (`AppletProfile::oath` returning `None`), not here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OathFeatures { + /// RENAME (`0x05`) — YubiKey 5.3+ / RS-Key. + pub rename: bool, + /// Require-touch property on credentials. + pub touch: bool, + /// SHA-512 credentials (accepted on the wire; some hosts hide it). + pub sha512: bool, + /// Access-code (password) protection. + pub password: bool, +} + +/// PIV applet features a firmware exposes, read by the PIV screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PivFeatures { + /// On-device key generation. + pub generate: bool, + /// Certificate import (PUT DATA). + pub import_cert: bool, + /// Attestation of generated keys. + pub attestation: bool, + /// The 20 retired key slots (82..95). + pub retired_slots: bool, +} + +/// OpenPGP applet features a firmware exposes, read by the OpenPGP screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpenPgpFeatures { + /// On-device key generation. + pub generate: bool, + /// Per-key touch (UIF) policy. + pub touch: bool, + /// Resetting-code — unblock PW1 without the admin PIN. + pub reset_code: bool, + /// Elliptic-curve keys (ECDSA / EdDSA / ECDH) beyond RSA. + pub ecc: bool, +} + +/// OTP applet features a firmware exposes, read by the Slots screen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OtpFeatures { + /// Programmable slots — 2 (classic YubiKey) or 4 (RS-Key extension). + pub slots: u8, + /// HMAC-SHA1 challenge-response programming. + pub chalresp: bool, + /// OATH-HOTP programming. + pub hotp: bool, + /// Static-password programming. + pub static_pw: bool, + /// Yubico-OTP programming. + pub yubiotp: bool, + /// Swap slots 1↔2. + pub swap: bool, +} diff --git a/src/hal/applets/oath.rs b/src/hal/applets/oath.rs new file mode 100644 index 0000000..46e0686 --- /dev/null +++ b/src/hal/applets/oath.rs @@ -0,0 +1,654 @@ +//! YKOATH (Yubico OATH) applet client — TOTP/HOTP account management. +//! +//! Speaks the standard Yubico OATH wire protocol over a +//! [`CcidSession`](crate::hal::transport::ccid::CcidSession), so it is +//! firmware-agnostic (RS-Key emulates the same AID and command set). The device +//! holds the secrets and computes the codes; the host only frames commands, +//! parses responses, and — for password-protected devices — derives the access +//! key (PBKDF2-HMAC-SHA1) and proves knowledge of it (HMAC-SHA1). + +// Full YKOATH surface; the Accounts screen wires most of it, and the rest +// (rename, bare LIST) is exercised by the inline tests or the follow-up UI. +#![allow(dead_code)] + +use crate::error::PFError; +use crate::hal::apdu::{tlv, Apdu, CLA_ISO}; +use crate::hal::transport::ccid::CcidSession; +use ring::rand::{SecureRandom, SystemRandom}; +use ring::{hmac, pbkdf2}; +use std::num::NonZeroU32; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Standard Yubico OATH applet AID. +pub const OATH_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x05, 0x27, 0x21, 0x01]; + +// Instructions. +const INS_PUT: u8 = 0x01; +const INS_DELETE: u8 = 0x02; +const INS_SET_CODE: u8 = 0x03; +const INS_RESET: u8 = 0x04; +const INS_RENAME: u8 = 0x05; +const INS_LIST: u8 = 0xA1; +const INS_CALCULATE: u8 = 0xA2; +const INS_VALIDATE: u8 = 0xA3; +const INS_CALCULATE_ALL: u8 = 0xA4; + +// Data-object tags. +const TAG_NAME: u32 = 0x71; +const TAG_NAME_LIST: u32 = 0x72; +const TAG_KEY: u32 = 0x73; +const TAG_CHALLENGE: u32 = 0x74; +const TAG_RESPONSE_FULL: u32 = 0x75; +const TAG_RESPONSE_TRUNC: u32 = 0x76; +const TAG_NO_RESPONSE: u32 = 0x77; +const TAG_PROPERTY: u32 = 0x78; +const TAG_VERSION: u32 = 0x79; +const TAG_IMF: u32 = 0x7A; +const TAG_TOUCH_RESPONSE: u32 = 0x7C; + +const PROP_TOUCH: u8 = 0x02; +const DEFAULT_PERIOD: u32 = 30; +const ACCESS_KEY_LEN: usize = 16; +const PBKDF2_ITERS: u32 = 1000; + +/// OATH credential kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OathType { + Totp, + Hotp, +} + +impl OathType { + fn wire(self) -> u8 { + match self { + Self::Hotp => 0x10, + Self::Totp => 0x20, + } + } + fn from_wire(b: u8) -> Self { + if b & 0xF0 == 0x10 { + Self::Hotp + } else { + Self::Totp + } + } +} + +/// HMAC hash used by a credential. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgo { + Sha1, + Sha256, + Sha512, +} + +impl HashAlgo { + fn wire(self) -> u8 { + match self { + Self::Sha1 => 0x01, + Self::Sha256 => 0x02, + Self::Sha512 => 0x03, + } + } + fn from_wire(b: u8) -> Self { + match b & 0x0F { + 0x02 => Self::Sha256, + 0x03 => Self::Sha512, + _ => Self::Sha1, + } + } + /// Label used in `otpauth://` URIs. + pub fn label(self) -> &'static str { + match self { + Self::Sha1 => "SHA1", + Self::Sha256 => "SHA256", + Self::Sha512 => "SHA512", + } + } +} + +/// The current code state for one credential after CALCULATE ALL. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CodeState { + /// A computed TOTP code, valid for `period` seconds from its window start. + Code { value: String, period: u32 }, + /// HOTP — counter-based; compute on demand with [`calculate`]. + Hotp, + /// Requires a physical touch before the device will compute the code. + Touch, +} + +/// A parsed OATH account (identity + current code state). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Account { + /// Raw credential id as stored on the device (the wire `0x71` name). + pub id: String, + pub issuer: Option, + pub account: String, + pub oath_type: OathType, + pub period: u32, + pub state: CodeState, +} + +/// A new credential to enroll via [`put`]. +#[derive(Debug, Clone)] +pub struct NewCredential { + pub issuer: Option, + pub account: String, + pub secret: Vec, + pub oath_type: OathType, + pub algorithm: HashAlgo, + pub digits: u8, + pub period: u32, + pub counter: u32, + pub touch: bool, +} + +/// Applet identity parsed from the SELECT response. +#[derive(Debug, Clone)] +pub struct OathInfo { + pub version: [u8; 3], + /// PBKDF2 salt (device id) used to derive the access key. + pub device_id: Vec, + /// SELECT challenge — present only when an access code is set. + pub challenge: Option>, +} + +impl OathInfo { + pub fn password_set(&self) -> bool { + self.challenge.is_some() + } +} + +/// Parse the OATH SELECT response. +pub fn parse_select(resp: &[u8]) -> OathInfo { + let mut version = [0u8; 3]; + if let Some(v) = tlv::find(resp, TAG_VERSION) { + for (i, b) in v.iter().take(3).enumerate() { + version[i] = *b; + } + } + OathInfo { + version, + device_id: tlv::find(resp, TAG_NAME).unwrap_or(&[]).to_vec(), + challenge: tlv::find(resp, TAG_CHALLENGE).map(|c| c.to_vec()), + } +} + +/// Open an OATH session (SELECT the applet). +pub fn open() -> Result<(CcidSession, OathInfo), PFError> { + let session = CcidSession::open(OATH_AID)?; + let info = parse_select(&session.select_resp); + Ok((session, info)) +} + +/// Derive the 16-byte access key from a password and the device id (salt). +pub fn derive_access_key(password: &str, device_id: &[u8]) -> [u8; ACCESS_KEY_LEN] { + let mut key = [0u8; ACCESS_KEY_LEN]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA1, + NonZeroU32::new(PBKDF2_ITERS).unwrap(), + device_id, + password.as_bytes(), + &mut key, + ); + key +} + +fn hmac_sha1(key: &[u8], msg: &[u8]) -> Vec { + let k = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key); + hmac::sign(&k, msg).as_ref().to_vec() +} + +fn random8() -> Result<[u8; 8], PFError> { + let mut c = [0u8; 8]; + SystemRandom::new() + .fill(&mut c) + .map_err(|_| PFError::Device("RNG failure".into()))?; + Ok(c) +} + +/// Unlock a password-protected applet with the derived access key and the +/// SELECT challenge, using YKOATH mutual authentication. +pub fn validate(session: &CcidSession, key: &[u8], select_challenge: &[u8]) -> Result<(), PFError> { + let response = hmac_sha1(key, select_challenge); + let host_challenge = random8()?; + let mut data = Vec::new(); + tlv::write(&mut data, TAG_RESPONSE_FULL, &response); + tlv::write(&mut data, TAG_CHALLENGE, &host_challenge); + session.transceive_full(&Apdu::read(CLA_ISO, INS_VALIDATE, 0, 0, &data))?; + Ok(()) +} + +/// Set (or replace) the applet access code. The device stores the key and +/// re-locks on the next SELECT. +pub fn set_code(session: &CcidSession, key: &[u8]) -> Result<(), PFError> { + let challenge = random8()?; + let response = hmac_sha1(key, &challenge); + let mut key_tlv = vec![HashAlgo::Sha1.wire()]; + key_tlv.extend_from_slice(key); + let mut data = Vec::new(); + tlv::write(&mut data, TAG_KEY, &key_tlv); + tlv::write(&mut data, TAG_CHALLENGE, &challenge); + tlv::write(&mut data, TAG_RESPONSE_FULL, &response); + session.transceive_full(&Apdu::write(CLA_ISO, INS_SET_CODE, 0, 0, &data))?; + Ok(()) +} + +/// Remove the access code (an empty key TLV). +pub fn clear_code(session: &CcidSession) -> Result<(), PFError> { + let mut data = Vec::new(); + tlv::write(&mut data, TAG_KEY, &[]); + session.transceive_full(&Apdu::write(CLA_ISO, INS_SET_CODE, 0, 0, &data))?; + Ok(()) +} + +/// Add or overwrite a credential. +pub fn put(session: &CcidSession, cred: &NewCredential) -> Result<(), PFError> { + let id = build_cred_id(cred.issuer.as_deref(), &cred.account, cred.oath_type, cred.period); + let mut key_tlv = vec![cred.oath_type.wire() | cred.algorithm.wire(), cred.digits]; + key_tlv.extend_from_slice(&cred.secret); + + let mut data = Vec::new(); + tlv::write(&mut data, TAG_NAME, id.as_bytes()); + tlv::write(&mut data, TAG_KEY, &key_tlv); + if cred.touch { + // Yubico quirk: the property object is a bare value with no length octet. + data.push(TAG_PROPERTY as u8); + data.push(PROP_TOUCH); + } + if cred.oath_type == OathType::Hotp && cred.counter != 0 { + tlv::write(&mut data, TAG_IMF, &cred.counter.to_be_bytes()); + } + session.transceive_full(&Apdu::write(CLA_ISO, INS_PUT, 0, 0, &data))?; + Ok(()) +} + +/// Delete a credential by its raw id. +pub fn delete(session: &CcidSession, id: &str) -> Result<(), PFError> { + let mut data = Vec::new(); + tlv::write(&mut data, TAG_NAME, id.as_bytes()); + session.transceive_full(&Apdu::write(CLA_ISO, INS_DELETE, 0, 0, &data))?; + Ok(()) +} + +/// Rename a credential (YubiKey 5.3+ / RS-Key). +pub fn rename(session: &CcidSession, old_id: &str, new_id: &str) -> Result<(), PFError> { + let mut data = Vec::new(); + tlv::write(&mut data, TAG_NAME, old_id.as_bytes()); + tlv::write(&mut data, TAG_NAME, new_id.as_bytes()); + session.transceive_full(&Apdu::write(CLA_ISO, INS_RENAME, 0, 0, &data))?; + Ok(()) +} + +/// Factory-reset the applet (destroys all credentials and the access code). +pub fn reset(session: &CcidSession) -> Result<(), PFError> { + session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET, 0xDE, 0xAD, &[]))?; + Ok(()) +} + +/// Compute one credential's code for a given period (used for HOTP and for +/// TOTP credentials whose period is not 30 s). +pub fn calculate(session: &CcidSession, id: &str, period: u32) -> Result { + let counter = time_counter(period.max(1)); + let mut data = Vec::new(); + tlv::write(&mut data, TAG_NAME, id.as_bytes()); + tlv::write(&mut data, TAG_CHALLENGE, &counter.to_be_bytes()); + // P2 = 1 → truncated response. + let resp = session.transceive_full(&Apdu::read(CLA_ISO, INS_CALCULATE, 0, 1, &data))?; + let (_, value) = tlv::TlvIter::new(&resp) + .next() + .ok_or_else(|| PFError::Device("Empty CALCULATE response".into()))?; + format_response(value) +} + +/// List every credential and its current code in one CALCULATE ALL round-trip, +/// re-computing any non-30 s TOTP credential individually (matches ykman). +pub fn calculate_all(session: &CcidSession) -> Result, PFError> { + let counter = time_counter(DEFAULT_PERIOD); + let mut req = Vec::new(); + tlv::write(&mut req, TAG_CHALLENGE, &counter.to_be_bytes()); + let resp = session.transceive_oath(&Apdu::read(CLA_ISO, INS_CALCULATE_ALL, 0, 1, &req))?; + + let mut accounts = Vec::new(); + let mut pending: Option<(String, OathType)> = None; + for (tag, value) in tlv::TlvIter::new(&resp) { + if tag == TAG_NAME { + let id = String::from_utf8_lossy(value).to_string(); + pending = Some((id, OathType::Totp)); + continue; + } + let Some((id, _)) = pending.take() else { continue }; + let (issuer, account, period) = parse_cred_id(&id); + let (oath_type, state) = match tag { + TAG_NO_RESPONSE => (OathType::Hotp, CodeState::Hotp), + TAG_TOUCH_RESPONSE => (OathType::Totp, CodeState::Touch), + TAG_RESPONSE_TRUNC | TAG_RESPONSE_FULL => { + let value_str = format_response(value)?; + ( + OathType::Totp, + CodeState::Code { value: value_str, period }, + ) + } + _ => continue, + }; + accounts.push(Account { + id, + issuer, + account, + oath_type, + period, + state, + }); + } + + // Non-30 s TOTP codes computed above used the 30 s window — fix them up. + for acc in accounts.iter_mut() { + if acc.oath_type == OathType::Totp + && acc.period != DEFAULT_PERIOD + && matches!(acc.state, CodeState::Code { .. }) + && let Ok(code) = calculate(session, &acc.id, acc.period) + { + acc.state = CodeState::Code { + value: code, + period: acc.period, + }; + } + } + Ok(accounts) +} + +/// Seconds remaining in the current window for a period. +pub fn seconds_remaining(period: u32) -> u32 { + let p = period.max(1); + let now = unix_now(); + p - (now % p as u64) as u32 +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn time_counter(period: u32) -> u64 { + unix_now() / period.max(1) as u64 +} + +/// Format a CALCULATE/CALCULATE ALL response value into the numeric code. +/// Handles both truncated (`0x76`: `[digits, 4-byte int]`) and full +/// (`0x75`: `[digits, full HMAC]`, truncated host-side) forms. +fn format_response(value: &[u8]) -> Result { + let digits = *value.first().ok_or_else(|| PFError::Device("Short code response".into()))?; + let body = &value[1..]; + let num = if body.len() == 4 { + u32::from_be_bytes([body[0], body[1], body[2], body[3]]) & 0x7FFF_FFFF + } else if body.len() >= 20 { + // Dynamic truncation of a full HMAC (RFC 4226). + let offset = (body[body.len() - 1] & 0x0F) as usize; + u32::from_be_bytes([ + body[offset], + body[offset + 1], + body[offset + 2], + body[offset + 3], + ]) & 0x7FFF_FFFF + } else { + return Err(PFError::Device("Malformed code response".into())); + }; + let modulo = 10u32.pow(digits.min(9) as u32); + Ok(format!("{:0width$}", num % modulo, width = digits as usize)) +} + +/// Build the Yubico credential id: `[/]:`, with the +/// period prefix only for non-30 s TOTP credentials. +pub fn build_cred_id(issuer: Option<&str>, account: &str, oath_type: OathType, period: u32) -> String { + let base = match issuer { + Some(i) if !i.is_empty() => format!("{i}:{account}"), + _ => account.to_string(), + }; + if oath_type == OathType::Totp && period != DEFAULT_PERIOD && period != 0 { + format!("{period}/{base}") + } else { + base + } +} + +/// Split a credential id back into `(issuer, account, period)`. +pub fn parse_cred_id(id: &str) -> (Option, String, u32) { + let (period, rest) = match id.split_once('/') { + Some((p, r)) if p.parse::().is_ok() => (p.parse().unwrap(), r), + _ => (DEFAULT_PERIOD, id), + }; + match rest.split_once(':') { + Some((issuer, account)) => (Some(issuer.to_string()), account.to_string(), period), + None => (None, rest.to_string(), period), + } +} + +/// Parse an `otpauth://` URI into a [`NewCredential`]. +pub fn parse_otpauth(uri: &str) -> Result { + let rest = uri + .strip_prefix("otpauth://") + .ok_or("Not an otpauth:// URI")?; + let (type_part, after) = rest.split_once('/').ok_or("Missing credential type")?; + let oath_type = match type_part.to_ascii_lowercase().as_str() { + "totp" => OathType::Totp, + "hotp" => OathType::Hotp, + _ => return Err("Type must be totp or hotp".into()), + }; + let (label, query) = match after.split_once('?') { + Some((l, q)) => (l, q), + None => (after, ""), + }; + let label = url_decode(label); + let (mut issuer, account) = match label.split_once(':') { + Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()), + None => (None, label.trim().to_string()), + }; + + let mut secret_b32 = None; + let mut algorithm = HashAlgo::Sha1; + let mut digits = 6u8; + let mut period = DEFAULT_PERIOD; + let mut counter = 0u32; + for pair in query.split('&').filter(|p| !p.is_empty()) { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + let v = url_decode(v); + match k.to_ascii_lowercase().as_str() { + "secret" => secret_b32 = Some(v), + "issuer" => { + if !v.is_empty() { + issuer = Some(v); + } + } + "algorithm" => { + algorithm = match v.to_ascii_uppercase().as_str() { + "SHA256" => HashAlgo::Sha256, + "SHA512" => HashAlgo::Sha512, + _ => HashAlgo::Sha1, + } + } + "digits" => digits = v.parse().unwrap_or(6), + "period" => period = v.parse().unwrap_or(DEFAULT_PERIOD), + "counter" => counter = v.parse().unwrap_or(0), + _ => {} + } + } + + let secret = base32_decode(&secret_b32.ok_or("Missing secret")?) + .ok_or("Invalid base32 secret")?; + if secret.is_empty() { + return Err("Empty secret".into()); + } + if account.is_empty() { + return Err("Missing account name".into()); + } + Ok(NewCredential { + issuer, + account, + secret, + oath_type, + algorithm, + digits: digits.clamp(6, 8), + period, + counter, + touch: false, + }) +} + +/// Decode RFC 4648 base32 (case-insensitive, padding and spaces ignored). +pub fn base32_decode(s: &str) -> Option> { + const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let mut bits = 0u32; + let mut nbits = 0u32; + let mut out = Vec::new(); + for c in s.chars() { + if c == '=' || c.is_whitespace() || c == '-' { + continue; + } + let up = (c as u8).to_ascii_uppercase(); + let v = ALPHABET.iter().position(|&x| x == up)? as u32; + bits = (bits << 5) | v; + nbits += 5; + if nbits >= 8 { + nbits -= 8; + out.push((bits >> nbits) as u8); + } + } + Some(out) +} + +/// Minimal percent-decoding (`+` → space, `%XX` → byte) for URI labels/params. +fn url_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'+' => { + out.push(b' '); + i += 1; + } + b'%' if i + 2 < bytes.len() => { + let hi = (bytes[i + 1] as char).to_digit(16); + let lo = (bytes[i + 2] as char).to_digit(16); + if let (Some(h), Some(l)) = (hi, lo) { + out.push((h * 16 + l) as u8); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8_lossy(&out).to_string() +} + +/// Parse a bare LIST response into credential ids + kinds (used when a code +/// isn't needed). `0x72` value = `[type|algo, name…]`. +pub fn parse_list(resp: &[u8]) -> Vec<(String, OathType, HashAlgo)> { + let mut out = Vec::new(); + for (tag, value) in tlv::TlvIter::new(resp) { + if tag == TAG_NAME_LIST && !value.is_empty() { + let props = value[0]; + let name = String::from_utf8_lossy(&value[1..]).to_string(); + out.push((name, OathType::from_wire(props), HashAlgo::from_wire(props))); + } + } + out +} + +/// LIST every credential id on the device (chained via SEND REMAINING). +pub fn list(session: &CcidSession) -> Result, PFError> { + let resp = session.transceive_oath(&Apdu::read(CLA_ISO, INS_LIST, 0, 0, &[]))?; + Ok(parse_list(&resp)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base32_decodes_known_vectors() { + assert_eq!(base32_decode("JBSWY3DPEHPK3PXP").unwrap(), b"Hello!\xde\xad\xbe\xef"); + assert_eq!(base32_decode("").unwrap(), Vec::::new()); + // Lowercase, spaces and padding tolerated. + assert_eq!(base32_decode("nb sw y3=dp").unwrap(), b"hello"); + assert!(base32_decode("0189").is_none()); // invalid symbols + } + + #[test] + fn cred_id_roundtrips() { + assert_eq!(build_cred_id(Some("GitHub"), "alice", OathType::Totp, 30), "GitHub:alice"); + assert_eq!(build_cred_id(Some("AWS"), "bob", OathType::Totp, 60), "60/AWS:bob"); + assert_eq!(build_cred_id(None, "solo", OathType::Hotp, 30), "solo"); + + assert_eq!( + parse_cred_id("GitHub:alice"), + (Some("GitHub".into()), "alice".into(), 30) + ); + assert_eq!(parse_cred_id("60/AWS:bob"), (Some("AWS".into()), "bob".into(), 60)); + assert_eq!(parse_cred_id("solo"), (None, "solo".into(), 30)); + // A slash that isn't a period prefix stays part of the account. + assert_eq!(parse_cred_id("a/b:c"), (Some("a/b".into()), "c".into(), 30)); + } + + #[test] + fn parses_otpauth_uri() { + let c = parse_otpauth( + "otpauth://totp/ACME%20Co:john@example.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA256&digits=8&period=60", + ) + .unwrap(); + assert_eq!(c.issuer.as_deref(), Some("ACME Co")); + assert_eq!(c.account, "john@example.com"); + assert_eq!(c.oath_type, OathType::Totp); + assert_eq!(c.algorithm, HashAlgo::Sha256); + assert_eq!(c.digits, 8); + assert_eq!(c.period, 60); + assert_eq!(c.secret, b"Hello!\xde\xad\xbe\xef"); + } + + #[test] + fn otpauth_requires_secret_and_type() { + assert!(parse_otpauth("otpauth://totp/x").is_err()); + assert!(parse_otpauth("https://totp/x?secret=AA").is_err()); + assert!(parse_otpauth("otpauth://sms/x?secret=AA").is_err()); + } + + #[test] + fn truncated_response_formats_code() { + // digits=6, truncated int 0x0000_04F0 = 1264 → "001264". + let value = [6u8, 0x00, 0x00, 0x04, 0xF0]; + assert_eq!(format_response(&value).unwrap(), "001264"); + } + + #[test] + fn full_hmac_response_dynamic_truncation() { + // RFC 4226 §5.4 canonical HMAC → 6-digit code 872921. + let hmac = [ + 0x1f, 0x86, 0x98, 0x69, 0x0e, 0x02, 0xca, 0x16, 0x61, 0x85, 0x50, 0xef, 0x7f, 0x19, + 0xda, 0x8e, 0x94, 0x5b, 0x55, 0x5a, + ]; + let mut value = vec![6u8]; + value.extend_from_slice(&hmac); + assert_eq!(format_response(&value).unwrap(), "872921"); + } + + #[test] + fn list_parses_type_and_algo() { + let mut resp = Vec::new(); + // 0x72: [0x21, "A:b"] → TOTP + SHA1. + tlv::write(&mut resp, TAG_NAME_LIST, &[0x21, b'A', b':', b'b']); + tlv::write(&mut resp, TAG_NAME_LIST, &[0x12, b'h']); // HOTP + SHA256 + let list = parse_list(&resp); + assert_eq!(list.len(), 2); + assert_eq!(list[0], ("A:b".to_string(), OathType::Totp, HashAlgo::Sha1)); + assert_eq!(list[1], ("h".to_string(), OathType::Hotp, HashAlgo::Sha256)); + } +} diff --git a/src/hal/applets/openpgp.rs b/src/hal/applets/openpgp.rs new file mode 100644 index 0000000..ff24f12 --- /dev/null +++ b/src/hal/applets/openpgp.rs @@ -0,0 +1,462 @@ +//! OpenPGP applet client — OpenPGP Card 3.4, ykman/GnuPG-compatible over CCID. +//! +//! Covers the management surface a config GUI needs: card status (cardholder, +//! PIN retries, per-slot key presence/algorithm/touch), PIN management (change +//! user/admin PIN, reset code, unblock), touch policy, on-device key generation, +//! cardholder editing, and factory reset. Key import from PEM and the raw PSO +//! sign/decrypt paths (gpg-driven) are out of scope. +//! +//! Management writes need PW3 (admin) verified on the SAME open session — SELECT +//! resets verification — so those ops open, VERIFY PW3, then act on one session. + +#![allow(dead_code)] + +use crate::error::PFError; +use crate::hal::apdu::{tlv, Apdu, CLA_ISO}; +use crate::hal::transport::ccid::CcidSession; + +pub const OPENPGP_AID: &[u8] = &[0xD2, 0x76, 0x00, 0x01, 0x24, 0x01]; + +// Instructions. +const INS_VERIFY: u8 = 0x20; +const INS_CHANGE_REF: u8 = 0x24; +const INS_RESET_RETRY: u8 = 0x2C; +const INS_ACTIVATE: u8 = 0x44; +const INS_GENERATE: u8 = 0x47; +const INS_GET_DATA: u8 = 0xCA; +const INS_PUT_DATA: u8 = 0xDA; +const INS_TERMINATE: u8 = 0xE6; +const INS_GET_VERSION: u8 = 0xF1; + +// PIN references. +pub const PW1: u8 = 0x81; +pub const PW3: u8 = 0x83; + +// CRT key-slot selectors (GENERATE) and their algo-attribute DO tags. +const CRT_SIG: u8 = 0xB6; +const CRT_DEC: u8 = 0xB8; +const CRT_AUT: u8 = 0xA4; + +// Algorithm ids (first byte of an algo-attribute DO). +const ALGO_RSA: u8 = 0x01; +const ALGO_ECDH: u8 = 0x12; +const ALGO_ECDSA: u8 = 0x13; +const ALGO_EDDSA: u8 = 0x16; + +pub const DEFAULT_PW1: &str = "123456"; +pub const DEFAULT_PW3: &str = "12345678"; + +// Curve OID bytes (the part after the algo id). +const OID_P256: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; +const OID_P384: &[u8] = &[0x2B, 0x81, 0x04, 0x00, 0x22]; +const OID_P521: &[u8] = &[0x2B, 0x81, 0x04, 0x00, 0x23]; +const OID_K256: &[u8] = &[0x2B, 0x81, 0x04, 0x00, 0x0A]; +const OID_BP256: &[u8] = &[0x2B, 0x24, 0x03, 0x03, 0x02, 0x08, 0x01, 0x01, 0x07]; +const OID_BP384: &[u8] = &[0x2B, 0x24, 0x03, 0x03, 0x02, 0x08, 0x01, 0x01, 0x0B]; +const OID_ED25519: &[u8] = &[0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01]; +const OID_X25519: &[u8] = &[0x2B, 0x06, 0x01, 0x04, 0x01, 0x97, 0x55, 0x01, 0x05, 0x01]; + +/// The three key slots. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PgpSlot { + Sig, + Dec, + Aut, +} + +impl PgpSlot { + pub fn label(self) -> &'static str { + match self { + Self::Sig => "Signature", + Self::Dec => "Encryption", + Self::Aut => "Authentication", + } + } + fn crt(self) -> u8 { + match self { + Self::Sig => CRT_SIG, + Self::Dec => CRT_DEC, + Self::Aut => CRT_AUT, + } + } + /// Algorithm-attribute DO tag (C1/C2/C3) and UIF touch DO tag (D6/D7/D8). + fn attr_tag(self) -> u16 { + match self { + Self::Sig => 0xC1, + Self::Dec => 0xC2, + Self::Aut => 0xC3, + } + } + fn uif_tag(self) -> u16 { + match self { + Self::Sig => 0xD6, + Self::Dec => 0xD7, + Self::Aut => 0xD8, + } + } +} + +/// Per-slot key metadata parsed from GET DATA. +#[derive(Debug, Clone)] +pub struct PgpKey { + pub slot: PgpSlot, + pub present: bool, + pub algo: String, + pub fingerprint: String, + pub touch: bool, +} + +/// Aggregated OpenPGP card status. +#[derive(Debug, Clone)] +pub struct PgpInfo { + pub version: [u8; 3], + pub serial: u32, + pub name: String, + pub login: String, + pub url: String, + /// Language preference (5F2D), ISO-639 2-char codes concatenated. + pub lang: String, + /// Sex (5F35): `0x31` male, `0x32` female, `0x39` not announced. + pub sex: u8, + pub pw1_retries: u8, + pub rc_retries: u8, + pub pw3_retries: u8, + pub keys: Vec, +} + +/// Algorithm choices offered in the generate wizard. `(key, label)`; the DEC +/// slot maps ECDSA→ECDH internally. +pub const GENERATE_ALGOS: &[(&str, u8)] = &[ + ("RSA-2048", 0), + ("RSA-3072", 1), + ("RSA-4096", 2), + ("ECC P-256", 3), + ("ECC P-384", 4), + ("ECC P-521", 5), + ("secp256k1", 6), + ("brainpoolP256r1", 7), + ("brainpoolP384r1", 8), + ("Ed25519 / Cv25519", 9), +]; + +/// Build the algorithm-attribute bytes for a slot + choice, or `None` if the +/// combination is unsupported (e.g. Ed25519 on the encryption slot). +pub fn algo_attr(slot: PgpSlot, choice: u8) -> Option> { + let ec_id = if slot == PgpSlot::Dec { ALGO_ECDH } else { ALGO_ECDSA }; + let ec = |oid: &[u8]| { + let mut v = vec![ec_id]; + v.extend_from_slice(oid); + Some(v) + }; + match choice { + 0 => Some(vec![ALGO_RSA, 0x08, 0x00, 0x00, 0x20, 0x00]), + 1 => Some(vec![ALGO_RSA, 0x0C, 0x00, 0x00, 0x20, 0x00]), + 2 => Some(vec![ALGO_RSA, 0x10, 0x00, 0x00, 0x20, 0x00]), + 3 => ec(OID_P256), + 4 => ec(OID_P384), + 5 => ec(OID_P521), + 6 => ec(OID_K256), + 7 => ec(OID_BP256), + 8 => ec(OID_BP384), + 9 => { + // 25519: Cv25519 (ECDH) for DEC, Ed25519 (EdDSA) otherwise. + let mut v = Vec::new(); + if slot == PgpSlot::Dec { + v.push(ALGO_ECDH); + v.extend_from_slice(OID_X25519); + } else { + v.push(ALGO_EDDSA); + v.extend_from_slice(OID_ED25519); + } + Some(v) + } + _ => None, + } +} + +fn algo_label(attr: &[u8]) -> String { + match attr.first().copied() { + Some(ALGO_RSA) if attr.len() >= 3 => { + format!("RSA-{}", u16::from_be_bytes([attr[1], attr[2]])) + } + Some(ALGO_ECDH) | Some(ALGO_ECDSA) | Some(ALGO_EDDSA) => curve_label(&attr[1..]), + _ => "unknown".to_string(), + } +} + +fn curve_label(oid: &[u8]) -> String { + let name = if oid == OID_P256 { + "ECC P-256" + } else if oid == OID_P384 { + "ECC P-384" + } else if oid == OID_P521 { + "ECC P-521" + } else if oid == OID_K256 { + "secp256k1" + } else if oid == OID_BP256 { + "brainpoolP256r1" + } else if oid == OID_BP384 { + "brainpoolP384r1" + } else if oid == OID_ED25519 { + "Ed25519" + } else if oid == OID_X25519 { + "Cv25519" + } else { + "EC" + }; + name.to_string() +} + +// ── Session + reads ───────────────────────────────────────────────────────── + +pub fn open() -> Result { + CcidSession::open(OPENPGP_AID) +} + +fn get_data(session: &CcidSession, tag: u16) -> Result, PFError> { + session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_DATA, (tag >> 8) as u8, tag as u8, &[])) +} + +pub fn get_version(session: &CcidSession) -> Result<[u8; 3], PFError> { + let r = session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_VERSION, 0, 0, &[]))?; + let mut v = [0u8; 3]; + v[..r.len().min(3)].copy_from_slice(&r[..r.len().min(3)]); + Ok(v) +} + +/// Decode packed-BCD serial bytes into a decimal serial number. +fn bcd_serial(b: &[u8]) -> u32 { + let mut n = 0u32; + for &byte in b { + n = n * 100 + (byte >> 4) as u32 * 10 + (byte & 0x0F) as u32; + } + n +} + +/// Full card status (unauthenticated). +pub fn read_info(session: &CcidSession) -> Result { + let version = get_version(session).unwrap_or([0; 3]); + let app = get_data(session, 0x6E)?; + let app = tlv::find(&app, 0x6E).unwrap_or(&app); + + let serial = tlv::find(app, 0x4F) + .filter(|a| a.len() >= 14) + .map(|a| bcd_serial(&a[10..14])) + .unwrap_or(0); + + let disc = tlv::find(app, 0x73).unwrap_or(app); + let pw = tlv::find(disc, 0xC4); + let (pw1_retries, rc_retries, pw3_retries) = pw + .filter(|p| p.len() >= 7) + .map(|p| (p[4], p[5], p[6])) + .unwrap_or((0, 0, 0)); + + let fps = tlv::find(disc, 0xC5).unwrap_or(&[]); + let key_info = tlv::find(disc, 0xDE).unwrap_or(&[]); + + let mut keys = Vec::new(); + for (i, slot) in [PgpSlot::Sig, PgpSlot::Dec, PgpSlot::Aut].into_iter().enumerate() { + let attr = tlv::find(disc, slot.attr_tag() as u32).unwrap_or(&[]); + let fp = fps.get(i * 20..i * 20 + 20).unwrap_or(&[]); + let present = fp.iter().any(|&b| b != 0) + || key_info + .get(i * 2 + 1) + .map(|&b| b != 0) + .unwrap_or(false); + let touch = tlv::find(disc, slot.uif_tag() as u32) + .and_then(|u| u.first()) + .map(|&b| b != 0) + .unwrap_or(false); + keys.push(PgpKey { + slot, + present, + algo: algo_label(attr), + fingerprint: hex::encode(fp), + touch, + }); + } + + // Cardholder: 65 { 5B name, 5F2D lang, 5F35 sex }, login 5E, url 5F50. + let ch = get_data(session, 0x65).unwrap_or_default(); + let ch = tlv::find(&ch, 0x65).unwrap_or(&ch); + let name = tlv::find(ch, 0x5B).map(str_of).unwrap_or_default(); + let lang = tlv::find(ch, 0x5F2D).map(str_of).unwrap_or_default(); + let sex = tlv::find(ch, 0x5F35) + .and_then(|v| v.first().copied()) + .unwrap_or(0x39); + let login = get_data(session, 0x5E).map(|v| str_of(&v)).unwrap_or_default(); + let url = get_data(session, 0x5F50).map(|v| str_of(&v)).unwrap_or_default(); + + Ok(PgpInfo { + version, + serial, + name, + login, + url, + lang, + sex, + pw1_retries, + rc_retries, + pw3_retries, + keys, + }) +} + +fn str_of(v: &[u8]) -> String { + String::from_utf8_lossy(v).trim_end_matches('\0').to_string() +} + +// ── PIN management ────────────────────────────────────────────────────────── + +pub fn verify_pin(session: &CcidSession, reference: u8, pin: &str) -> Result<(), PFError> { + session.transceive_full(&Apdu::write(CLA_ISO, INS_VERIFY, 0x00, reference, pin.as_bytes()))?; + Ok(()) +} + +/// Change PW1 (`ref=PW1`) or PW3 (`ref=PW3`). The device splits old/new at the +/// stored PIN length, so send `old ‖ new` concatenated. +pub fn change_pin(session: &CcidSession, reference: u8, old: &str, new: &str) -> Result<(), PFError> { + let mut body = old.as_bytes().to_vec(); + body.extend_from_slice(new.as_bytes()); + session.transceive_full(&Apdu::write(CLA_ISO, INS_CHANGE_REF, 0x00, reference, &body))?; + Ok(()) +} + +/// Unblock PW1 with the resetting code (`RC ‖ new_pw1`). +pub fn unblock_with_rc(session: &CcidSession, rc: &str, new_pw1: &str) -> Result<(), PFError> { + let mut body = rc.as_bytes().to_vec(); + body.extend_from_slice(new_pw1.as_bytes()); + session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET_RETRY, 0x00, PW1, &body))?; + Ok(()) +} + +/// Unblock PW1 using a verified admin PIN (call `verify_pin(PW3)` first). +pub fn unblock_with_admin(session: &CcidSession, new_pw1: &str) -> Result<(), PFError> { + session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET_RETRY, 0x02, PW1, new_pw1.as_bytes()))?; + Ok(()) +} + +/// Set (or clear, if empty) the resetting code — PUT DATA D3 (PW3 first). +pub fn set_reset_code(session: &CcidSession, new_rc: &str) -> Result<(), PFError> { + put_data(session, 0xD3, new_rc.as_bytes()) +} + +// ── PUT DATA (PW3-gated writes) ───────────────────────────────────────────── + +fn put_data(session: &CcidSession, tag: u16, value: &[u8]) -> Result<(), PFError> { + session.transceive_full(&Apdu::write( + CLA_ISO, + INS_PUT_DATA, + (tag >> 8) as u8, + tag as u8, + value, + ))?; + Ok(()) +} + +pub fn set_cardholder( + session: &CcidSession, + name: &str, + login: &str, + url: &str, + lang: &str, + sex: u8, +) -> Result<(), PFError> { + put_data(session, 0x5B, name.as_bytes())?; + put_data(session, 0x5E, login.as_bytes())?; + put_data(session, 0x5F50, url.as_bytes())?; + put_data(session, 0x5F2D, lang.as_bytes())?; + put_data(session, 0x5F35, &[sex])?; + Ok(()) +} + +pub fn set_touch(session: &CcidSession, slot: PgpSlot, on: bool) -> Result<(), PFError> { + put_data(session, slot.uif_tag(), &[if on { 0x01 } else { 0x00 }, 0x20]) +} + +pub fn set_algo_attr(session: &CcidSession, slot: PgpSlot, attr: &[u8]) -> Result<(), PFError> { + put_data(session, slot.attr_tag(), attr) +} + +/// Set the slot's algorithm then GENERATE a key (returns the `7F49` public key). +pub fn generate( + session: &CcidSession, + slot: PgpSlot, + attr: &[u8], +) -> Result, PFError> { + set_algo_attr(session, slot, attr)?; + session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERATE, 0x80, 0x00, &[slot.crt(), 0x00])) +} + +// ── Factory reset (block both PINs → TERMINATE → ACTIVATE) ─────────────────── + +pub fn reset(session: &CcidSession) -> Result<(), PFError> { + for reference in [PW1, PW3] { + for _ in 0..10 { + match session.transceive(&Apdu::write(CLA_ISO, INS_VERIFY, 0x00, reference, b"00000000")) { + Ok((_, sw)) if sw.0 == 0x6983 => break, + Ok(_) => continue, + Err(e) => return Err(e), + } + } + } + session.transceive_full(&Apdu::write(CLA_ISO, INS_TERMINATE, 0x00, 0x00, &[]))?; + session.transceive_full(&Apdu::write(CLA_ISO, INS_ACTIVATE, 0x00, 0x00, &[]))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn algo_attr_bytes() { + assert_eq!(algo_attr(PgpSlot::Sig, 0).unwrap(), vec![0x01, 0x08, 0x00, 0x00, 0x20, 0x00]); + assert_eq!(algo_attr(PgpSlot::Sig, 2).unwrap(), vec![0x01, 0x10, 0x00, 0x00, 0x20, 0x00]); + // P-256: ECDSA on SIG, ECDH on DEC, same OID. + assert_eq!(algo_attr(PgpSlot::Sig, 3).unwrap()[0], ALGO_ECDSA); + assert_eq!(algo_attr(PgpSlot::Dec, 3).unwrap()[0], ALGO_ECDH); + assert_eq!(&algo_attr(PgpSlot::Sig, 3).unwrap()[1..], OID_P256); + // 25519: Ed25519 on SIG, Cv25519 on DEC. + assert_eq!(algo_attr(PgpSlot::Sig, 9).unwrap()[0], ALGO_EDDSA); + assert_eq!(algo_attr(PgpSlot::Dec, 9).unwrap()[0], ALGO_ECDH); + } + + #[test] + fn labels_from_attrs() { + assert_eq!(algo_label(&[0x01, 0x10, 0x00, 0x00, 0x20, 0x00]), "RSA-4096"); + assert_eq!(algo_label(&[0x13, 0x2B, 0x81, 0x04, 0x00, 0x22]), "ECC P-384"); + assert_eq!(algo_label(&[0x16, 0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01]), "Ed25519"); + } + + #[test] + fn bcd_serial_decode() { + assert_eq!(bcd_serial(&[0x01, 0x23, 0x45, 0x67]), 1234567); + assert_eq!(bcd_serial(&[0x00, 0x00, 0x00, 0x00]), 0); + } + + #[test] + fn parses_app_data_tree() { + // Build a minimal 6E { 4F , 73 { C4 pw, C5 fps, DE keyinfo, C1 attr } }. + let mut aid = vec![0xD2, 0x76, 0x00, 0x01, 0x24, 0x01, 0x03, 0x04, 0x00, 0x06]; + aid.extend_from_slice(&[0x00, 0x12, 0x34, 0x56]); // serial BCD = 123456 + aid.extend_from_slice(&[0x00, 0x00]); + let mut disc = Vec::new(); + tlv::write(&mut disc, 0xC1, &[0x01, 0x08, 0x00, 0x00, 0x20, 0x00]); + tlv::write(&mut disc, 0xC4, &[0x01, 0x7F, 0x7F, 0x7F, 0x03, 0x00, 0x02]); + tlv::write(&mut disc, 0xC5, &[0u8; 60]); // no fingerprints + tlv::write(&mut disc, 0xDE, &[0x01, 0x00, 0x02, 0x00, 0x03, 0x00]); + let mut app = Vec::new(); + tlv::write(&mut app, 0x4F, &aid); + tlv::write(&mut app, 0x73, &disc); + let mut full = Vec::new(); + tlv::write(&mut full, 0x6E, &app); + + let a = tlv::find(&full, 0x6E).unwrap(); + assert_eq!(bcd_serial(&tlv::find(a, 0x4F).unwrap()[10..14]), 123456); + let d = tlv::find(a, 0x73).unwrap(); + let c4 = tlv::find(d, 0xC4).unwrap(); + assert_eq!((c4[4], c4[5], c4[6]), (0x03, 0x00, 0x02)); // pw1/rc/pw3 retries + assert_eq!(algo_label(tlv::find(d, 0xC1).unwrap()), "RSA-2048"); + } +} diff --git a/src/hal/applets/otp.rs b/src/hal/applets/otp.rs new file mode 100644 index 0000000..49e7329 --- /dev/null +++ b/src/hal/applets/otp.rs @@ -0,0 +1,594 @@ +//! Yubico OTP applet client — the two (RS-Key: four) configurable slots. +//! +//! Speaks the standard YubiKey slot protocol over CCID (single INS `0x01`, +//! P1 selects the operation). Slot config records are the classic 52-byte packed +//! frame with a trailing CRC-16 (residual `0xF0B8`). The device stores the +//! secrets; the host builds the frame and reads status. +//! +//! v1 programs Challenge-response (HMAC-SHA1) and OATH-HOTP — both byte-exact +//! from RS-Key firmware. Static-password (needs a scancode map) and Yubico-OTP +//! (needs a public id + upload) programming are a follow-up; status/delete/swap +//! already cover all four slot types the device may hold. + +// Some frame builders / calculate are a staged surface; not all are UI-wired yet. +#![allow(dead_code)] + +use crate::error::PFError; +use crate::hal::apdu::{tlv, Apdu, CLA_ISO}; +use crate::hal::transport::ccid::CcidSession; +use ring::rand::{SecureRandom, SystemRandom}; + +/// Yubico OTP applet AID. +pub const OTP_AID: &[u8] = &[0xA0, 0x00, 0x00, 0x05, 0x27, 0x20, 0x01]; + +const INS_OTP: u8 = 0x01; + +// Slot-command P1 codes. +const P1_CONFIG_SLOT1: u8 = 0x01; +const P1_CONFIG_SLOT2: u8 = 0x03; +const P1_SWAP: u8 = 0x06; +const P1_STATUS_EXT: u8 = 0x14; +const P1_CHAL_HMAC_SLOT1: u8 = 0x30; +const P1_CHAL_HMAC_SLOT2: u8 = 0x38; + +/// HMAC-SHA1 challenge-response frame size. The slot takes a fixed 64-byte +/// challenge; the firmware rejects a shorter one with `6700`. +const CHALLENGE_FRAME: usize = 64; + +// 52-byte config frame offsets. +const OFF_UID: usize = 16; +const OFF_AES_KEY: usize = 22; +const OFF_ACC_CODE: usize = 38; +const OFF_FIXED_SIZE: usize = 44; +const OFF_EXT_FLAGS: usize = 45; +const OFF_TKT_FLAGS: usize = 46; +const OFF_CFG_FLAGS: usize = 47; +const CONFIG_SIZE: usize = 52; +const ACC_CODE_SIZE: usize = 6; +const SECRET_LEN: usize = 20; + +// Ticket (TKT) flags. +const TKT_OATH_HOTP: u8 = 0x40; +const TKT_CHAL_RESP: u8 = 0x40; +const TKT_APPEND_CR: u8 = 0x20; + +// Config (CFG) flags. +const CFG_SHORT_TICKET: u8 = 0x02; +const CFG_OATH_HOTP8: u8 = 0x02; +const CFG_HMAC_LT64: u8 = 0x04; +const CFG_CHAL_BTN_TRIG: u8 = 0x08; +const CFG_STATIC_TICKET: u8 = 0x20; +const CFG_CHAL_YUBICO: u8 = 0x20; +const CFG_CHAL_HMAC: u8 = 0x22; + +/// What a programmed slot holds (best-effort classification from its flags). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SlotType { + Empty, + YubicoOtp, + StaticPassword, + OathHotp, + ChallengeResponse, +} + +impl SlotType { + pub fn label(self) -> &'static str { + match self { + Self::Empty => "Empty", + Self::YubicoOtp => "Yubico OTP", + Self::StaticPassword => "Static password", + Self::OathHotp => "OATH-HOTP", + Self::ChallengeResponse => "Challenge-response", + } + } +} + +/// Status of one slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SlotInfo { + /// 1-based slot number (1/2 classic, 3/4 = RS-Key extension). + pub slot: u8, + pub kind: SlotType, + pub touch: bool, +} + +impl SlotInfo { + fn empty(slot: u8) -> Self { + Self { slot, kind: SlotType::Empty, touch: false } + } + pub fn configured(&self) -> bool { + self.kind != SlotType::Empty + } +} + +fn classify(tkt: u8, cfg: u8) -> SlotType { + if tkt & TKT_CHAL_RESP != 0 { + if cfg & CFG_CHAL_YUBICO != 0 { + SlotType::ChallengeResponse + } else { + SlotType::OathHotp + } + } else if cfg & (CFG_STATIC_TICKET | CFG_SHORT_TICKET) != 0 { + SlotType::StaticPassword + } else { + SlotType::YubicoOtp + } +} + +/// Slot 1-based number → the `(P1, P2)` addressing a config/delete command uses. +/// Slot 1 = `01/0`, slot 2 = `03/0`, slots 3/4 = `01` with a P2 offset. +fn config_p1p2(slot: u8) -> (u8, u8) { + match slot { + 2 => (P1_CONFIG_SLOT2, 0), + 3 => (P1_CONFIG_SLOT1, 2), + 4 => (P1_CONFIG_SLOT1, 3), + _ => (P1_CONFIG_SLOT1, 0), + } +} + +/// Slot → `(P1, P2)` for an HMAC challenge-response command. +fn chal_p1p2(slot: u8) -> (u8, u8) { + match slot { + 2 => (P1_CHAL_HMAC_SLOT2, 0), + 3 => (P1_CHAL_HMAC_SLOT1, 2), + 4 => (P1_CHAL_HMAC_SLOT1, 3), + _ => (P1_CHAL_HMAC_SLOT1, 0), + } +} + +/// CRC-16 (X.25, reflected poly 0x8408, init 0xFFFF). A valid config CRCs the +/// whole 52 bytes to the residual 0xF0B8. +fn crc16(data: &[u8]) -> u16 { + let mut crc = 0xFFFFu16; + for &b in data { + crc ^= b as u16; + for _ in 0..8 { + let lsb = crc & 1; + crc >>= 1; + if lsb != 0 { + crc ^= 0x8408; + } + } + } + crc +} + +#[allow(clippy::too_many_arguments)] +fn build_config( + fixed: &[u8; 16], + uid: &[u8; 6], + key: &[u8; 16], + acc: &[u8; 6], + fixed_size: u8, + tkt: u8, + cfg: u8, +) -> [u8; CONFIG_SIZE] { + let mut c = [0u8; CONFIG_SIZE]; + c[..16].copy_from_slice(fixed); + c[OFF_UID..OFF_UID + 6].copy_from_slice(uid); + c[OFF_AES_KEY..OFF_AES_KEY + 16].copy_from_slice(key); + c[OFF_ACC_CODE..OFF_ACC_CODE + 6].copy_from_slice(acc); + c[OFF_FIXED_SIZE] = fixed_size; + c[OFF_TKT_FLAGS] = tkt; + c[OFF_CFG_FLAGS] = cfg; + // Stored CRC = ~crc16(first 50 bytes), little-endian (Yubico convention). + let crc = !crc16(&c[..CONFIG_SIZE - 2]); + c[CONFIG_SIZE - 2..].copy_from_slice(&crc.to_le_bytes()); + c +} + +/// Split a secret (≤20 bytes) into the AES-key + UID fields the way ykman does. +fn key_uid(secret: &[u8]) -> ([u8; 16], [u8; 6]) { + let mut key = [0u8; 16]; + let mut uid = [0u8; 6]; + let n = secret.len().min(SECRET_LEN); + let kn = n.min(16); + key[..kn].copy_from_slice(&secret[..kn]); + if n > 16 { + uid[..n - 16].copy_from_slice(&secret[16..n]); + } + (key, uid) +} + +/// Build an HMAC-SHA1 challenge-response config (variable-length challenges). +pub fn build_chalresp(secret: &[u8], touch: bool, new_acc: &[u8; 6]) -> [u8; CONFIG_SIZE] { + let (key, uid) = key_uid(secret); + let mut cfg = CFG_CHAL_HMAC | CFG_HMAC_LT64; + if touch { + cfg |= CFG_CHAL_BTN_TRIG; + } + build_config(&[0u8; 16], &uid, &key, new_acc, 0, TKT_CHAL_RESP, cfg) +} + +/// Build an OATH-HOTP config (6 or 8 digits, optional trailing CR). +pub fn build_hotp( + secret: &[u8], + digits8: bool, + append_cr: bool, + new_acc: &[u8; 6], +) -> [u8; CONFIG_SIZE] { + let (key, uid) = key_uid(secret); + let mut tkt = TKT_OATH_HOTP; + if append_cr { + tkt |= TKT_APPEND_CR; + } + let cfg = if digits8 { CFG_OATH_HOTP8 } else { 0 }; + build_config(&[0u8; 16], &uid, &key, new_acc, 0, tkt, cfg) +} + +/// Build a static-password config: the given HID scancodes are typed verbatim. +pub fn build_static(scancodes: &[u8], append_cr: bool, new_acc: &[u8; 6]) -> [u8; CONFIG_SIZE] { + let mut buf = [0u8; 38]; + let n = scancodes.len().min(38); + buf[..n].copy_from_slice(&scancodes[..n]); + let mut fixed = [0u8; 16]; + fixed.copy_from_slice(&buf[..16]); + let mut uid = [0u8; 6]; + uid.copy_from_slice(&buf[16..22]); + let mut key = [0u8; 16]; + key.copy_from_slice(&buf[22..38]); + let mut tkt = 0; + if append_cr { + tkt |= TKT_APPEND_CR; + } + build_config(&fixed, &uid, &key, new_acc, n as u8, tkt, CFG_STATIC_TICKET) +} + +/// Build a Yubico-OTP config (public id ‖ private id ‖ AES key). +pub fn build_yubico_otp( + public_id: &[u8], + private_id: &[u8; 6], + key: &[u8; 16], + append_cr: bool, + new_acc: &[u8; 6], +) -> [u8; CONFIG_SIZE] { + let mut fixed = [0u8; 16]; + let n = public_id.len().min(16); + fixed[..n].copy_from_slice(&public_id[..n]); + let mut tkt = 0; + if append_cr { + tkt |= TKT_APPEND_CR; + } + build_config(&fixed, private_id, key, new_acc, n as u8, tkt, 0) +} + +const MODHEX: &[u8; 16] = b"cbdefghijklnrtuv"; + +/// Decode a modhex string (Yubico's keyboard-safe hex) into bytes. +pub fn modhex_decode(s: &str) -> Option> { + let s = s.trim(); + if s.is_empty() || s.len() % 2 != 0 { + return None; + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(s.len() / 2); + for pair in bytes.chunks(2) { + let hi = MODHEX.iter().position(|&c| c == pair[0].to_ascii_lowercase())? as u8; + let lo = MODHEX.iter().position(|&c| c == pair[1].to_ascii_lowercase())? as u8; + out.push((hi << 4) | lo); + } + Some(out) +} + +/// Encode bytes as modhex. +pub fn modhex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(MODHEX[(b >> 4) as usize] as char); + s.push(MODHEX[(b & 0x0F) as usize] as char); + } + s +} + +/// Map an ASCII character to its US-keyboard HID scancode (bit 0x80 = shift), +/// the byte format YubiKey static-password slots type verbatim. +fn scancode(ch: char) -> Option { + const SHIFT: u8 = 0x80; + Some(match ch { + 'a'..='z' => 0x04 + (ch as u8 - b'a'), + 'A'..='Z' => SHIFT | (0x04 + (ch as u8 - b'A')), + '1'..='9' => 0x1E + (ch as u8 - b'1'), + '0' => 0x27, + ' ' => 0x2C, + '-' => 0x2D, + '=' => 0x2E, + '[' => 0x2F, + ']' => 0x30, + '\\' => 0x31, + ';' => 0x33, + '\'' => 0x34, + '`' => 0x35, + ',' => 0x36, + '.' => 0x37, + '/' => 0x38, + '!' => SHIFT | 0x1E, + '@' => SHIFT | 0x1F, + '#' => SHIFT | 0x20, + '$' => SHIFT | 0x21, + '%' => SHIFT | 0x22, + '^' => SHIFT | 0x23, + '&' => SHIFT | 0x24, + '*' => SHIFT | 0x25, + '(' => SHIFT | 0x26, + ')' => SHIFT | 0x27, + '_' => SHIFT | 0x2D, + '+' => SHIFT | 0x2E, + '{' => SHIFT | 0x2F, + '}' => SHIFT | 0x30, + '|' => SHIFT | 0x31, + ':' => SHIFT | 0x33, + '"' => SHIFT | 0x34, + '~' => SHIFT | 0x35, + '<' => SHIFT | 0x36, + '>' => SHIFT | 0x37, + '?' => SHIFT | 0x38, + _ => return None, + }) +} + +/// Convert an ASCII password to HID scancodes (≤38 chars), or `None` if it has +/// an unmappable character or is too long. +pub fn ascii_to_scancodes(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len()); + for ch in s.chars() { + out.push(scancode(ch)?); + } + if out.is_empty() || out.len() > 38 { + return None; + } + Some(out) +} + +/// A 20-byte cryptographically-random secret. +pub fn random_secret() -> Result<[u8; SECRET_LEN], PFError> { + let mut s = [0u8; SECRET_LEN]; + SystemRandom::new() + .fill(&mut s) + .map_err(|_| PFError::Device("RNG failure".into()))?; + Ok(s) +} + +/// Random (public id, private id, AES key) for a self-generated Yubico OTP slot. +pub fn random_yubico() -> Result<([u8; 6], [u8; 6], [u8; 16]), PFError> { + let mut buf = [0u8; 28]; + SystemRandom::new() + .fill(&mut buf) + .map_err(|_| PFError::Device("RNG failure".into()))?; + let mut public_id = [0u8; 6]; + let mut private_id = [0u8; 6]; + let mut key = [0u8; 16]; + public_id.copy_from_slice(&buf[..6]); + private_id.copy_from_slice(&buf[6..12]); + key.copy_from_slice(&buf[12..28]); + Ok((public_id, private_id, key)) +} + +/// Open the OTP applet (SELECT). +pub fn open() -> Result { + CcidSession::open(OTP_AID) +} + +/// Read per-slot status via EXTENDED STATUS (`0x14`). Always returns four +/// entries (slots 1-4); absent slots are `SlotType::Empty`. +pub fn read_info(session: &CcidSession) -> Result<[SlotInfo; 4], PFError> { + let resp = session.transceive_full(&Apdu::read(CLA_ISO, INS_OTP, P1_STATUS_EXT, 0, &[]))?; + let mut slots = [ + SlotInfo::empty(1), + SlotInfo::empty(2), + SlotInfo::empty(3), + SlotInfo::empty(4), + ]; + for (tag, value) in tlv::TlvIter::new(&resp) { + if (0xB0..=0xB3).contains(&tag) + && let Some(flags) = tlv::find(value, 0xA0) + && flags.len() >= 2 + { + let (tkt, cfg) = (flags[0], flags[1]); + let idx = (tag - 0xB0) as usize; + slots[idx] = SlotInfo { + slot: idx as u8 + 1, + kind: classify(tkt, cfg), + touch: cfg & CFG_CHAL_BTN_TRIG != 0, + }; + } + } + Ok(slots) +} + +/// Write a slot config. `current_acc` is the slot's existing access code (zeros +/// when it is unprotected); a protected slot with the wrong code returns `6982`. +pub fn configure( + session: &CcidSession, + slot: u8, + config: &[u8; CONFIG_SIZE], + current_acc: &[u8; 6], +) -> Result<(), PFError> { + let (p1, p2) = config_p1p2(slot); + let mut body = config.to_vec(); + body.extend_from_slice(current_acc); + session.transceive_full(&Apdu::read(CLA_ISO, INS_OTP, p1, p2, &body))?; + Ok(()) +} + +/// Delete (zap) a slot — an all-zero config write. +pub fn delete_slot(session: &CcidSession, slot: u8, current_acc: &[u8; 6]) -> Result<(), PFError> { + let (p1, p2) = config_p1p2(slot); + let mut body = vec![0u8; CONFIG_SIZE]; + body.extend_from_slice(current_acc); + session.transceive_full(&Apdu::read(CLA_ISO, INS_OTP, p1, p2, &body))?; + Ok(()) +} + +/// Swap the contents of slots 1 and 2. An empty body swaps unprotected slots; +/// a `[0, 0, acc…]` body presents an access code. +pub fn swap(session: &CcidSession, current_acc: &[u8; 6]) -> Result<(), PFError> { + let body = if current_acc.iter().all(|&b| b == 0) { + Vec::new() + } else { + let mut b = vec![0u8, 0u8]; + b.extend_from_slice(current_acc); + b + }; + session.transceive_full(&Apdu::read(CLA_ISO, INS_OTP, P1_SWAP, 0, &body))?; + Ok(()) +} + +/// Pad a variable-length challenge into the fixed 64-byte frame the slot +/// expects. A shorter challenge is filled with a byte that differs from its +/// last byte, because the firmware recovers the message by trimming trailing +/// bytes equal to the frame's final byte — so the pad must not match, or the +/// tail of the challenge would be trimmed away. +fn pad_challenge(challenge: &[u8]) -> Result<[u8; CHALLENGE_FRAME], PFError> { + if challenge.is_empty() || challenge.len() > CHALLENGE_FRAME { + return Err(PFError::Device(format!( + "Challenge must be 1..={CHALLENGE_FRAME} bytes" + ))); + } + let mut frame = [0u8; CHALLENGE_FRAME]; + frame[..challenge.len()].copy_from_slice(challenge); + if challenge.len() < CHALLENGE_FRAME { + let pad = if *challenge.last().unwrap() == 0x7F { 0x00 } else { 0x7F }; + frame[challenge.len()..].fill(pad); + } + Ok(frame) +} + +/// Run an HMAC-SHA1 challenge-response against a slot (returns the 20-byte MAC). +pub fn calculate_hmac( + session: &CcidSession, + slot: u8, + challenge: &[u8], +) -> Result, PFError> { + let frame = pad_challenge(challenge)?; + let (p1, p2) = chal_p1p2(slot); + session.transceive_full(&Apdu::read(CLA_ISO, INS_OTP, p1, p2, &frame)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NO_ACC: [u8; 6] = [0; 6]; + + #[test] + fn config_crc_residual_is_valid() { + // Every builder must produce a frame that CRCs to the X.25 residual. + assert_eq!(crc16(&build_chalresp(&[0x11; 20], false, &NO_ACC)), 0xF0B8); + assert_eq!(crc16(&build_hotp(&[0xAB; 20], true, true, &NO_ACC)), 0xF0B8); + assert_eq!(crc16(&build_static(&[0x04, 0x05, 0x06], false, &NO_ACC)), 0xF0B8); + assert_eq!(crc16(&build_yubico_otp(&[1; 6], &[2; 6], &[3; 16], false, &NO_ACC)), 0xF0B8); + } + + #[test] + fn chalresp_layout() { + let mut secret = [0u8; 20]; + for (i, b) in secret.iter_mut().enumerate() { + *b = i as u8; + } + let c = build_chalresp(&secret, true, &NO_ACC); + assert_eq!(c[OFF_TKT_FLAGS], 0x40); // TKT_CHAL_RESP + assert_eq!(c[OFF_CFG_FLAGS], 0x22 | 0x04 | 0x08); // HMAC | LT64 | BTN_TRIG + assert_eq!(&c[OFF_AES_KEY..OFF_AES_KEY + 16], &secret[..16]); + assert_eq!(&c[OFF_UID..OFF_UID + 4], &secret[16..20]); + assert_eq!(&c[OFF_UID + 4..OFF_UID + 6], &[0, 0]); + } + + #[test] + fn hotp_layout() { + let c6 = build_hotp(&[0xAB; 20], false, false, &NO_ACC); + assert_eq!(c6[OFF_TKT_FLAGS], 0x40); // TKT_OATH_HOTP + assert_eq!(c6[OFF_CFG_FLAGS], 0x00); // 6 digits + let c8 = build_hotp(&[0xAB; 20], true, true, &NO_ACC); + assert_eq!(c8[OFF_TKT_FLAGS], 0x40 | 0x20); // + APPEND_CR + assert_eq!(c8[OFF_CFG_FLAGS], 0x02); // OATH_HOTP8 + } + + #[test] + fn static_and_yubico_layout() { + let s = build_static(&[0x04, 0x05, 0x06, 0x07], true, &NO_ACC); + assert_eq!(s[OFF_CFG_FLAGS], CFG_STATIC_TICKET); + assert_eq!(s[OFF_TKT_FLAGS], TKT_APPEND_CR); + assert_eq!(s[OFF_FIXED_SIZE], 4); + assert_eq!(&s[..4], &[0x04, 0x05, 0x06, 0x07]); + + let y = build_yubico_otp(&[0xAA; 6], &[0xBB; 6], &[0xCC; 16], false, &NO_ACC); + assert_eq!(y[OFF_TKT_FLAGS], 0); // plain OTP + assert_eq!(y[OFF_CFG_FLAGS], 0); + assert_eq!(y[OFF_FIXED_SIZE], 6); + assert_eq!(&y[..6], &[0xAA; 6]); + assert_eq!(&y[OFF_UID..OFF_UID + 6], &[0xBB; 6]); + } + + #[test] + fn access_code_is_embedded() { + let acc = [1u8, 2, 3, 4, 5, 6]; + let c = build_chalresp(&[0; 20], false, &acc); + assert_eq!(&c[OFF_ACC_CODE..OFF_ACC_CODE + 6], &acc); + assert_eq!(crc16(&c), 0xF0B8); + } + + #[test] + fn modhex_roundtrips() { + assert_eq!(modhex_encode(&[0x2d, 0x34, 0x4f]), "dteffv"); + assert_eq!(modhex_decode("dteffv").unwrap(), vec![0x2d, 0x34, 0x4f]); + assert!(modhex_decode("zzzz").is_none()); // not modhex letters + assert!(modhex_decode("cvc").is_none()); // odd length + } + + #[test] + fn scancodes_map_us_keyboard() { + assert_eq!(ascii_to_scancodes("a").unwrap(), vec![0x04]); + assert_eq!(ascii_to_scancodes("A").unwrap(), vec![0x80 | 0x04]); + assert_eq!(ascii_to_scancodes("1!").unwrap(), vec![0x1E, 0x80 | 0x1E]); + assert!(ascii_to_scancodes("héllo").is_none()); // non-ASCII + assert!(ascii_to_scancodes(&"x".repeat(39)).is_none()); // too long + } + + #[test] + fn classify_types() { + assert_eq!(classify(0x40, 0x26), SlotType::ChallengeResponse); + assert_eq!(classify(0x40, 0x00), SlotType::OathHotp); + assert_eq!(classify(0x40, 0x02), SlotType::OathHotp); + assert_eq!(classify(0x00, 0x20), SlotType::StaticPassword); + assert_eq!(classify(0x00, 0x00), SlotType::YubicoOtp); + } + + /// Mirror the firmware's LT64 trim (strip trailing bytes == the last frame + /// byte) to prove the padded frame round-trips back to the challenge. + fn firmware_trim(frame: &[u8; CHALLENGE_FRAME]) -> &[u8] { + let last = frame[CHALLENGE_FRAME - 1]; + let mut n = CHALLENGE_FRAME; + while n > 0 && frame[n - 1] == last { + n -= 1; + } + &frame[..n] + } + + #[test] + fn hmac_challenge_pads_to_64_and_round_trips() { + let chal = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let frame = pad_challenge(&chal).unwrap(); + assert_eq!(&frame[..8], &chal); + assert_eq!(frame[8], 0x7F); + assert_eq!(frame[CHALLENGE_FRAME - 1], 0x7F); + assert_eq!(firmware_trim(&frame), &chal); + } + + #[test] + fn hmac_challenge_ending_in_pad_byte_uses_alt_fill() { + // Last byte == the default pad (0x7F) → fill with 0x00 so the trim stops + // at the challenge boundary instead of eating the final 0x7F. + let chal = [0xAAu8, 0x7F]; + let frame = pad_challenge(&chal).unwrap(); + assert_eq!(frame[2], 0x00); + assert_eq!(firmware_trim(&frame), &chal); + } + + #[test] + fn hmac_challenge_length_bounds() { + assert!(pad_challenge(&[]).is_err()); + assert!(pad_challenge(&[0u8; CHALLENGE_FRAME + 1]).is_err()); + assert!(pad_challenge(&[0u8; CHALLENGE_FRAME]).is_ok()); + assert_eq!(pad_challenge(&[9u8; CHALLENGE_FRAME]).unwrap().len(), CHALLENGE_FRAME); + } +} diff --git a/src/hal/applets/piv.rs b/src/hal/applets/piv.rs new file mode 100644 index 0000000..1c6a560 --- /dev/null +++ b/src/hal/applets/piv.rs @@ -0,0 +1,1007 @@ +//! PIV applet client — YubiKey-PIV-compatible over CCID. +//! +//! Covers the management surface a config GUI needs: slot/PIN/mgmt status +//! (GET METADATA + GET DATA), PIN/PUK/management-key management, key GENERATE, +//! certificate import/export, and factory reset. Raw private-key crypto +//! (sign/decrypt/ECDH) and key import from PEM are out of scope (driven by +//! ssh/age/PKCS#11), so they are not implemented here. +//! +//! Byte layout follows the RS-Key rsk-piv wire spec; the management-gated ops +//! (GENERATE, PUT DATA, SET MGM, SET PIN RETRIES) require a GENERAL AUTHENTICATE +//! mutual-auth first — SELECT resets the session, so auth and the gated op MUST +//! run on the same open [`CcidSession`]. + +#![allow(dead_code)] + +use crate::error::PFError; +use crate::hal::apdu::{tlv, Apdu, CLA_CHAIN, CLA_ISO}; +use crate::hal::transport::ccid::CcidSession; +use cbc::cipher::{Block, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit}; +use ring::rand::{SecureRandom, SystemRandom}; + +/// Full PIV AID. +pub const PIV_AID: &[u8] = &[ + 0xA0, 0x00, 0x00, 0x03, 0x08, 0x00, 0x00, 0x10, 0x00, 0x01, 0x00, +]; + +// Instructions. +const INS_VERIFY: u8 = 0x20; +const INS_CHANGE_REF: u8 = 0x24; +const INS_RESET_RETRY: u8 = 0x2C; +const INS_GENERATE: u8 = 0x47; +const INS_GENERAL_AUTH: u8 = 0x87; +const INS_GET_DATA: u8 = 0xCB; +const INS_PUT_DATA: u8 = 0xDB; +const INS_MOVE: u8 = 0xF6; +const INS_GET_METADATA: u8 = 0xF7; +const INS_GET_SERIAL: u8 = 0xF8; +const INS_ATTEST: u8 = 0xF9; +const INS_IMPORT: u8 = 0xFE; +const INS_SET_RETRIES: u8 = 0xFA; +const INS_RESET: u8 = 0xFB; +const INS_GET_VERSION: u8 = 0xFD; +const INS_SET_MGM: u8 = 0xFF; + +// Slots / references. +pub const REF_PIN: u8 = 0x80; +pub const REF_PUK: u8 = 0x81; +pub const SLOT_9A: u8 = 0x9A; +pub const SLOT_9C: u8 = 0x9C; +pub const SLOT_9D: u8 = 0x9D; +pub const SLOT_9E: u8 = 0x9E; +pub const SLOT_MGM: u8 = 0x9B; +/// The four primary key slots. +pub const PRIMARY_SLOTS: [u8; 4] = [SLOT_9A, SLOT_9C, SLOT_9D, SLOT_9E]; + +// Algorithm ids (NON-contiguous RSA ids — a common encoder bug). +pub const ALGO_3DES: u8 = 0x03; +pub const ALGO_RSA3072: u8 = 0x05; +pub const ALGO_RSA1024: u8 = 0x06; +pub const ALGO_RSA2048: u8 = 0x07; +pub const ALGO_AES128: u8 = 0x08; +pub const ALGO_AES192: u8 = 0x0A; +pub const ALGO_AES256: u8 = 0x0C; +pub const ALGO_ECCP256: u8 = 0x11; +pub const ALGO_ECCP384: u8 = 0x14; +pub const ALGO_RSA4096: u8 = 0x16; +pub const ALGO_ED25519: u8 = 0xE0; +pub const ALGO_X25519: u8 = 0xE1; + +/// Key algorithms offered in the generate wizard (RSA-1024 omitted — weak and +/// refused under the firmware's fips profile). +pub const GENERATE_ALGOS: &[u8] = &[ + ALGO_ECCP256, + ALGO_ECCP384, + ALGO_ED25519, + ALGO_X25519, + ALGO_RSA2048, + ALGO_RSA3072, + ALGO_RSA4096, +]; + +// PIN / touch policy + origin. +pub const PIN_POLICY_DEFAULT: u8 = 0; +pub const PIN_POLICY_NEVER: u8 = 1; +pub const PIN_POLICY_ONCE: u8 = 2; +pub const PIN_POLICY_ALWAYS: u8 = 3; +pub const TOUCH_POLICY_DEFAULT: u8 = 0; +pub const TOUCH_POLICY_NEVER: u8 = 1; +pub const TOUCH_POLICY_ALWAYS: u8 = 2; +pub const TOUCH_POLICY_CACHED: u8 = 3; +pub const ORIGIN_GENERATED: u8 = 0x01; +pub const ORIGIN_IMPORTED: u8 = 0x02; + +// General-auth TLV tags. +const TAG_DYN_AUTH: u32 = 0x7C; +const TAG_WITNESS: u32 = 0x80; +const TAG_CHALLENGE: u32 = 0x81; +const TAG_RESPONSE: u32 = 0x82; + +// Object / template tags. +const TAG_DATA_PATH: u32 = 0x5C; +const TAG_DATA_OBJECT: u32 = 0x53; +const TAG_GEN_TEMPLATE: u32 = 0xAC; +const TAG_GEN_ALGO: u32 = 0x80; +const TAG_PIN_POLICY: u32 = 0x0AA; +const TAG_TOUCH_POLICY: u32 = 0x0AB; + +/// The YubiKey factory default 24-byte management key, typed AES-192. +pub const DEFAULT_MGM_KEY: [u8; 24] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 6, 7, 8, +]; +pub const DEFAULT_PIN: &str = "123456"; +pub const DEFAULT_PUK: &str = "12345678"; + +/// A key slot's metadata (from GET METADATA), or `None` when the slot is empty. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SlotMeta { + pub algo: u8, + pub pin_policy: u8, + pub touch_policy: u8, + pub origin: u8, +} + +/// Status of one primary key slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SlotStatus { + pub slot: u8, + pub meta: Option, + pub has_cert: bool, +} + +/// Retry / default status of a PIN or PUK. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RefStatus { + pub is_default: bool, + pub total: u8, + pub left: u8, +} + +/// Aggregated PIV card status shown on the screen. +#[derive(Debug, Clone)] +pub struct PivInfo { + pub version: [u8; 3], + pub serial: u32, + pub pin: Option, + pub puk: Option, + pub mgm_algo: u8, + pub mgm_default: bool, + /// The management key is PIN-protected (ykman `--protect`): it isn't typed as + /// hex; it's fetched from the PRINTED object after a PIN VERIFY. + pub mgm_protected: bool, + pub slots: Vec, +} + +/// Human label for a key algorithm. +pub fn algo_label(algo: u8) -> &'static str { + match algo { + ALGO_RSA1024 => "RSA-1024", + ALGO_RSA2048 => "RSA-2048", + ALGO_RSA3072 => "RSA-3072", + ALGO_RSA4096 => "RSA-4096", + ALGO_ECCP256 => "ECC P-256", + ALGO_ECCP384 => "ECC P-384", + ALGO_ED25519 => "Ed25519", + ALGO_X25519 => "X25519", + ALGO_3DES => "3DES", + ALGO_AES128 => "AES-128", + ALGO_AES192 => "AES-192", + ALGO_AES256 => "AES-256", + _ => "Unknown", + } +} + +pub fn slot_label(slot: u8) -> &'static str { + match slot { + SLOT_9A => "Authentication (9A)", + SLOT_9C => "Signature (9C)", + SLOT_9D => "Key Management (9D)", + SLOT_9E => "Card Authentication (9E)", + _ => "Slot", + } +} + +/// Object id (`5F C1 xx`) of a slot's certificate. +pub fn cert_object_id(slot: u8) -> [u8; 3] { + let low = match slot { + SLOT_9A => 0x05, + SLOT_9C => 0x0A, + SLOT_9D => 0x0B, + SLOT_9E => 0x01, + s if (0x82..=0x95).contains(&s) => 0x0D + (s - 0x82), // retired R1..R20 → 5FC10D..5FC120 + _ => 0x05, + }; + [0x5F, 0xC1, low] +} + +// ── SELECT + open ─────────────────────────────────────────────────────────── + +pub fn open() -> Result { + CcidSession::open(PIV_AID) +} + +// ── Simple reads (no auth) ────────────────────────────────────────────────── + +pub fn get_version(session: &CcidSession) -> Result<[u8; 3], PFError> { + let r = session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_VERSION, 0, 0, &[]))?; + let mut v = [0u8; 3]; + v[..r.len().min(3)].copy_from_slice(&r[..r.len().min(3)]); + Ok(v) +} + +pub fn get_serial(session: &CcidSession) -> Result { + let r = session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_SERIAL, 0, 0, &[]))?; + if r.len() < 4 { + return Ok(0); + } + Ok(u32::from_be_bytes([r[0], r[1], r[2], r[3]])) +} + +/// Raw GET METADATA response for a slot/reference (P1=0, P2=slot). +fn get_metadata(session: &CcidSession, slot: u8) -> Result, PFError> { + session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_METADATA, 0, slot, &[])) +} + +pub fn parse_ref_status(resp: &[u8]) -> Option { + let is_default = tlv::find(resp, 0x05).map(|v| v.first() == Some(&1)).unwrap_or(false); + let retry = tlv::find(resp, 0x06)?; + if retry.len() < 2 { + return None; + } + Some(RefStatus { is_default, total: retry[0], left: retry[1] }) +} + +pub fn parse_slot_meta(resp: &[u8]) -> Option { + let algo = *tlv::find(resp, 0x01)?.first()?; + let policy = tlv::find(resp, 0x02)?; + let origin = tlv::find(resp, 0x03).and_then(|v| v.first().copied()).unwrap_or(0); + Some(SlotMeta { + algo, + pin_policy: *policy.first().unwrap_or(&0), + touch_policy: *policy.get(1).unwrap_or(&0), + origin, + }) +} + +/// Read a data object (certificate/CHUID/…) — returns the unwrapped `53` value. +fn get_data(session: &CcidSession, object_id: &[u8]) -> Result, PFError> { + let mut body = Vec::new(); + tlv::write(&mut body, TAG_DATA_PATH, object_id); + let resp = session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_DATA, 0x3F, 0xFF, &body))?; + Ok(tlv::find(&resp, TAG_DATA_OBJECT).map(|v| v.to_vec()).unwrap_or(resp)) +} + +/// Extract the bare DER certificate from a slot's data object (`53{70 …}`). +pub fn cert_der(object: &[u8]) -> Option> { + tlv::find(object, 0x70).map(|v| v.to_vec()) +} + +/// Full card status. All reads here are unauthenticated. +pub fn read_info(session: &CcidSession) -> Result { + let version = get_version(session).unwrap_or([0; 3]); + let serial = get_serial(session).unwrap_or(0); + let pin = get_metadata(session, REF_PIN).ok().and_then(|r| parse_ref_status(&r)); + let puk = get_metadata(session, REF_PUK).ok().and_then(|r| parse_ref_status(&r)); + + let (mgm_algo, mgm_default) = match get_metadata(session, SLOT_MGM) { + Ok(r) => ( + tlv::find(&r, 0x01).and_then(|v| v.first().copied()).unwrap_or(ALGO_AES192), + tlv::find(&r, 0x05).map(|v| v.first() == Some(&1)).unwrap_or(false), + ), + Err(_) => (ALGO_AES192, false), + }; + + let mut slots = Vec::new(); + for &slot in &PRIMARY_SLOTS { + let meta = get_metadata(session, slot).ok().and_then(|r| parse_slot_meta(&r)); + let has_cert = get_data(session, &cert_object_id(slot)) + .ok() + .and_then(|o| cert_der(&o)) + .map(|d| !d.is_empty()) + .unwrap_or(false); + slots.push(SlotStatus { slot, meta, has_cert }); + } + + let mgm_protected = mgm_is_protected(session); + + Ok(PivInfo { version, serial, pin, puk, mgm_algo, mgm_default, mgm_protected, slots }) +} + +// ── PIN-protected management key (ykman --protect) ─────────────────────────── + +/// ADMIN DATA (PivmanData) object — carries the --protect flag. +const OBJ_ADMIN_DATA: [u8; 3] = [0x5F, 0xFF, 0x00]; +/// PRINTED object — holds the PIN-protected management key. +const OBJ_PRINTED: [u8; 3] = [0x5F, 0xC1, 0x09]; +const PIVMAN_TAG: u32 = 0x80; +const PIVMAN_FLAGS_TAG: u32 = 0x81; +const PIVMAN_FLAG_MGM_PROTECTED: u8 = 0x02; +const PROTECTED_OUTER_TAG: u32 = 0x88; +const PROTECTED_MGM_TAG: u32 = 0x89; + +/// Whether the management key is PIN-protected. Reads ADMIN DATA (`5FFF00`, +/// `80 { 81 }`) and tests the mgm-protected flag bit; a missing/malformed +/// object reads as not protected (fail-open to the hex path). +fn mgm_is_protected(session: &CcidSession) -> bool { + let Ok(obj) = get_data(session, &OBJ_ADMIN_DATA) else { + return false; + }; + let inner = tlv::find(&obj, 0x53).unwrap_or(&obj); + let Some(pivman) = tlv::find(inner, PIVMAN_TAG) else { + return false; + }; + tlv::find(pivman, PIVMAN_FLAGS_TAG) + .and_then(|f| f.first().copied()) + .map(|f| f & PIVMAN_FLAG_MGM_PROTECTED != 0) + .unwrap_or(false) +} + +/// Fetch the PIN-protected management key: VERIFY the PIN, then GET DATA PRINTED +/// (`53 { 88 { 89 } }`). Only valid on a `--protect`'d card. +pub fn read_protected_mgm(session: &CcidSession, pin: &str) -> Result, PFError> { + verify_pin(session, pin)?; + let obj = get_data(session, &OBJ_PRINTED)?; + parse_protected_mgm(&obj) + .ok_or_else(|| PFError::Device("No PIN-protected management key on this card".into())) +} + +/// Parse the PRINTED object `53 { 88 { 89 } }` to the raw key bytes. +fn parse_protected_mgm(obj: &[u8]) -> Option> { + let inner = tlv::find(obj, 0x53).unwrap_or(obj); + let protected = tlv::find(inner, PROTECTED_OUTER_TAG)?; + let key = tlv::find(protected, PROTECTED_MGM_TAG)?; + matches!(key.len(), 16 | 24 | 32).then(|| key.to_vec()) +} + +/// The AES management-key algorithm id for a key of `len` bytes. +pub fn mgm_algo_for_len(len: usize) -> u8 { + match len { + 16 => ALGO_AES128, + 32 => ALGO_AES256, + _ => ALGO_AES192, + } +} + +/// Export a slot's certificate DER (empty error if none). +pub fn export_cert(session: &CcidSession, slot: u8) -> Result, PFError> { + let obj = get_data(session, &cert_object_id(slot))?; + cert_der(&obj) + .filter(|d| !d.is_empty()) + .ok_or_else(|| PFError::Device("No certificate in this slot".into())) +} + +// ── PIN / PUK management (no session auth, burns retries) ──────────────────── + +/// PIN/PUK padded to 8 bytes with `0xFF`. +fn pad8(s: &[u8]) -> [u8; 8] { + let mut p = [0xFFu8; 8]; + let n = s.len().min(8); + p[..n].copy_from_slice(&s[..n]); + p +} + +pub fn verify_pin(session: &CcidSession, pin: &str) -> Result<(), PFError> { + let block = pad8(pin.as_bytes()); + session.transceive_full(&Apdu::write(CLA_ISO, INS_VERIFY, 0x00, REF_PIN, &block))?; + Ok(()) +} + +/// The 16-byte CHANGE REFERENCE / RESET RETRY body: current then new secret, +/// each the standard 8-byte PIV block (`0xFF`-padded). The firmware stores every +/// verifier over its 8-byte-padded form and splits the command at 8, so the +/// current block must be a full 8 — an unpadded one mis-verifies and burns a +/// retry even when the secret is correct. +fn change_ref_body(current: &str, new: &str) -> Vec { + let mut body = pad8(current.as_bytes()).to_vec(); + body.extend_from_slice(&pad8(new.as_bytes())); + body +} + +/// Change PIN (`ref`=REF_PIN) or PUK (`ref`=REF_PUK). +pub fn change_ref( + session: &CcidSession, + reference: u8, + old: &str, + new: &str, +) -> Result<(), PFError> { + let body = change_ref_body(old, new); + session.transceive_full(&Apdu::write(CLA_ISO, INS_CHANGE_REF, 0x00, reference, &body))?; + Ok(()) +} + +/// Unblock the PIN using the PUK (RESET RETRY COUNTER). +pub fn unblock_pin(session: &CcidSession, puk: &str, new_pin: &str) -> Result<(), PFError> { + let body = change_ref_body(puk, new_pin); + session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET_RETRY, 0x00, REF_PIN, &body))?; + Ok(()) +} + +// ── Management mutual-auth (0x87) ─────────────────────────────────────────── + +/// One-block AES ECB via CBC with a zero IV (E/D of a single block under CBC and +/// IV=0 is exactly ECB). Reuses the `aes`/`cbc` crates already in the tree. +fn aes_ecb(key: &[u8], block: &mut [u8; 16], encrypt: bool) -> Result<(), PFError> { + let iv = [0u8; 16]; + macro_rules! run { + ($cipher:ty) => {{ + let mut b = Block::<$cipher>::try_from(&block[..]) + .map_err(|_| PFError::Device("AES block size".into()))?; + if encrypt { + cbc::Encryptor::<$cipher>::new_from_slices(key, &iv) + .map_err(|_| PFError::Device("Bad management key".into()))? + .encrypt_block(&mut b); + } else { + cbc::Decryptor::<$cipher>::new_from_slices(key, &iv) + .map_err(|_| PFError::Device("Bad management key".into()))? + .decrypt_block(&mut b); + } + block.copy_from_slice(b.as_slice()); + }}; + } + match key.len() { + 16 => run!(aes::Aes128), + 24 => run!(aes::Aes192), + 32 => run!(aes::Aes256), + _ => return Err(PFError::Device("Management key must be AES (16/24/32 bytes)".into())), + } + Ok(()) +} + +/// Authenticate the management key on this session (witness mutual-auth). Must +/// precede any mgmt-gated op on the same open session. AES keys only (3DES is +/// not implemented — the default key is AES-192). +pub fn authenticate_mgm(session: &CcidSession, key: &[u8], algo: u8) -> Result<(), PFError> { + if algo == ALGO_3DES { + // A 3DES 9B key returns an 8-byte witness this AES-only path can't + // process; fail with the real reason, not a generic "Bad witness". + return Err(PFError::Device( + "3DES management keys are not supported — set an AES management key first".into(), + )); + } + // Step 1: request the encrypted witness. + let mut req1 = Vec::new(); + tlv::write(&mut req1, TAG_DYN_AUTH, &{ + let mut inner = Vec::new(); + tlv::write(&mut inner, TAG_WITNESS, &[]); + inner + }); + let r1 = session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERAL_AUTH, algo, SLOT_MGM, &req1))?; + let outer = tlv::find(&r1, TAG_DYN_AUTH).ok_or_else(|| PFError::Device("No 7C in auth".into()))?; + let enc_witness = tlv::find(outer, TAG_WITNESS) + .filter(|w| w.len() == 16) + .ok_or_else(|| PFError::Device("Bad witness".into()))?; + let mut witness = [0u8; 16]; + witness.copy_from_slice(enc_witness); + aes_ecb(key, &mut witness, false)?; // decrypt → R + + // Step 2: return the decrypted witness + our own challenge. + let mut challenge = [0u8; 16]; + SystemRandom::new() + .fill(&mut challenge) + .map_err(|_| PFError::Device("RNG failure".into()))?; + let mut inner = Vec::new(); + tlv::write(&mut inner, TAG_WITNESS, &witness); + tlv::write(&mut inner, TAG_CHALLENGE, &challenge); + let mut req2 = Vec::new(); + tlv::write(&mut req2, TAG_DYN_AUTH, &inner); + let r2 = session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERAL_AUTH, algo, SLOT_MGM, &req2))?; + + // Verify the card's response encrypts our challenge (mutual auth). + let outer2 = tlv::find(&r2, TAG_DYN_AUTH).ok_or_else(|| PFError::Device("No 7C in auth-2".into()))?; + let resp = tlv::find(outer2, TAG_RESPONSE) + .filter(|r| r.len() == 16) + .ok_or_else(|| PFError::Device("Bad auth response".into()))?; + let mut expect = challenge; + aes_ecb(key, &mut expect, true)?; + if expect.as_slice() != resp { + return Err(PFError::Device("Management-key authentication failed".into())); + } + Ok(()) +} + +// ── Management-gated ops (require prior authenticate_mgm on same session) ───── + +/// Build the GENERATE command template `AC{80 01 algo [AA 01 pp][AB 01 tp]}`. +pub fn generate_template(algo: u8, pin_policy: u8, touch_policy: u8) -> Vec { + let mut inner = Vec::new(); + tlv::write(&mut inner, TAG_GEN_ALGO, &[algo]); + if pin_policy != PIN_POLICY_DEFAULT { + tlv::write(&mut inner, TAG_PIN_POLICY, &[pin_policy]); + } + if touch_policy != TOUCH_POLICY_DEFAULT { + tlv::write(&mut inner, TAG_TOUCH_POLICY, &[touch_policy]); + } + let mut out = Vec::new(); + tlv::write(&mut out, TAG_GEN_TEMPLATE, &inner); + out +} + +/// Generate a key in `slot`. Returns the raw `7F49` public-key response. +pub fn generate( + session: &CcidSession, + slot: u8, + algo: u8, + pin_policy: u8, + touch_policy: u8, +) -> Result, PFError> { + let body = generate_template(algo, pin_policy, touch_policy); + session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERATE, 0x00, slot, &body)) +} + +/// Wrap a DER certificate into a PIV data object (`70 71 01 00 FE 00`). +pub fn wrap_cert_object(der: &[u8]) -> Vec { + let mut inner = Vec::new(); + tlv::write(&mut inner, 0x70, der); + tlv::write(&mut inner, 0x71, &[0x00]); + tlv::write(&mut inner, 0xFE, &[]); + inner +} + +/// PUT DATA (mgmt-gated). Uses command chaining for large objects. +pub fn put_data(session: &CcidSession, object_id: &[u8], data: &[u8]) -> Result<(), PFError> { + let mut body = Vec::new(); + tlv::write(&mut body, TAG_DATA_PATH, object_id); + tlv::write(&mut body, TAG_DATA_OBJECT, data); + let apdu = Apdu { + cla: CLA_ISO, + ins: INS_PUT_DATA, + p1: 0x3F, + p2: 0xFF, + data: body, + le: None, + }; + session.send_chained(&apdu)?; + Ok(()) +} + +/// Import a certificate into a slot (mgmt-gated). +pub fn import_cert(session: &CcidSession, slot: u8, der: &[u8]) -> Result<(), PFError> { + put_data(session, &cert_object_id(slot), &wrap_cert_object(der)) +} + +/// Delete a slot's certificate (mgmt-gated) — an empty data object. +pub fn delete_cert(session: &CcidSession, slot: u8) -> Result<(), PFError> { + put_data(session, &cert_object_id(slot), &[]) +} + +/// A random management key of the length for `algo` (AES-128/192/256). +pub fn random_key(algo: u8) -> Result, PFError> { + let len = match algo { + ALGO_AES128 => 16, + ALGO_AES256 => 32, + _ => 24, + }; + let mut k = vec![0u8; len]; + SystemRandom::new() + .fill(&mut k) + .map_err(|_| PFError::Device("RNG failure".into()))?; + Ok(k) +} + +/// Set a new management key (mgmt-gated). `touch` sets touch-always. +pub fn set_mgm(session: &CcidSession, algo: u8, key: &[u8], touch: bool) -> Result<(), PFError> { + let mut body = vec![algo, SLOT_MGM, key.len() as u8]; + body.extend_from_slice(key); + let p2 = if touch { 0xFE } else { 0xFF }; + session.transceive_full(&Apdu::write(CLA_ISO, INS_SET_MGM, 0xFF, p2, &body))?; + Ok(()) +} + +/// Set PIN/PUK retry counts (requires mgmt AND PIN; resets PIN/PUK to defaults). +pub fn set_retries(session: &CcidSession, pin_tries: u8, puk_tries: u8) -> Result<(), PFError> { + session.transceive_full(&Apdu::read(CLA_ISO, INS_SET_RETRIES, pin_tries, puk_tries, &[]))?; + Ok(()) +} + +/// Attest a slot's (generated) key — returns the bare DER attestation cert. +pub fn attest(session: &CcidSession, slot: u8) -> Result, PFError> { + session.transceive_full(&Apdu::read(CLA_ISO, INS_ATTEST, slot, 0, &[])) +} + +/// Move a key from `src` to `dst` slot (mgmt-gated). +pub fn move_key(session: &CcidSession, src: u8, dst: u8) -> Result<(), PFError> { + session.transceive_full(&Apdu::write(CLA_ISO, INS_MOVE, dst, src, &[]))?; + Ok(()) +} + +/// Delete a slot's key entirely (mgmt-gated) — MOVE with the delete sentinel. +pub fn delete_key(session: &CcidSession, slot: u8) -> Result<(), PFError> { + session.transceive_full(&Apdu::write(CLA_ISO, INS_MOVE, 0xFF, slot, &[]))?; + Ok(()) +} + +/// Import pre-built key-material TLVs into a slot (mgmt-gated). `algo` is P1. +pub fn import_key(session: &CcidSession, slot: u8, algo: u8, material: &[u8]) -> Result<(), PFError> { + let apdu = Apdu { + cla: CLA_ISO, + ins: INS_IMPORT, + p1: algo, + p2: slot, + data: material.to_vec(), + le: None, + }; + session.send_chained(&apdu)?; + Ok(()) +} + +// ── PEM/DER private-key parsing (for IMPORT) ──────────────────────────────── + +const OID_RSA: &[u8] = &[0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]; +const OID_EC: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01]; +const OID_P256: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]; +const OID_P384: &[u8] = &[0x2B, 0x81, 0x04, 0x00, 0x22]; +const OID_ED25519: &[u8] = &[0x2B, 0x65, 0x70]; +const OID_X25519: &[u8] = &[0x2B, 0x65, 0x6E]; + +fn der_len(d: &[u8], p: &mut usize) -> Option { + let first = *d.get(*p)?; + *p += 1; + if first < 0x80 { + return Some(first as usize); + } + let n = (first & 0x7F) as usize; + if n == 0 || n > 4 { + return None; + } + let mut len = 0usize; + for _ in 0..n { + len = (len << 8) | *d.get(*p)? as usize; + *p += 1; + } + Some(len) +} + +/// Read one DER TLV (single-byte tags only — sufficient for key structures). +fn der_tlv<'a>(d: &'a [u8], p: &mut usize) -> Option<(u8, &'a [u8])> { + let tag = *d.get(*p)?; + *p += 1; + let len = der_len(d, p)?; + let end = p.checked_add(len)?; + if end > d.len() { + return None; + } + let v = &d[*p..end]; + *p = end; + Some((tag, v)) +} + +/// Strip the leading sign byte from a DER INTEGER's magnitude. +fn int_bytes(v: &[u8]) -> &[u8] { + if v.first() == Some(&0) && v.len() > 1 { + &v[1..] + } else { + v + } +} + +/// Left-pad a scalar to a fixed field size. +fn pad_left(v: &[u8], n: usize) -> Vec { + let v = int_bytes(v); + if v.len() >= n { + return v[v.len() - n..].to_vec(); + } + let mut out = vec![0u8; n]; + out[n - v.len()..].copy_from_slice(v); + out +} + +/// Decode a PEM or DER private key into `(algo_id, IMPORT command data)`. +pub fn parse_private_key(input: &[u8]) -> Result<(u8, Vec), String> { + let (der, label) = strip_pem(input)?; + match label.as_deref() { + Some("RSA") => parse_rsa(&der), + Some("EC") => parse_ec(&der, None), + _ => parse_pkcs8(&der), + } +} + +fn strip_pem(input: &[u8]) -> Result<(Vec, Option), String> { + let text = std::str::from_utf8(input).unwrap_or(""); + let Some(begin) = text.find("-----BEGIN ") else { + return Ok((input.to_vec(), None)); + }; + let after = &text[begin + 11..]; + let label_end = after.find("-----").ok_or("Malformed PEM header")?; + let label = &after[..label_end]; + let body = &after[label_end + 5..]; + let end = body.find("-----END").ok_or("Malformed PEM footer")?; + let b64: String = body[..end].chars().filter(|c| !c.is_whitespace()).collect(); + use base64::Engine; + let der = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|_| "Invalid base64 in PEM")?; + let kind = if label.contains("RSA") { + "RSA" + } else if label.contains("EC") { + "EC" + } else { + "PKCS8" + }; + Ok((der, Some(kind.to_string()))) +} + +fn parse_pkcs8(der: &[u8]) -> Result<(u8, Vec), String> { + let mut p = 0; + let (_, seq) = der_tlv(der, &mut p).ok_or("Not a DER SEQUENCE")?; + let mut q = 0; + der_tlv(seq, &mut q).ok_or("PKCS8 version")?; // version + let (_, alg) = der_tlv(seq, &mut q).ok_or("PKCS8 algorithm")?; + let (_, priv_octets) = der_tlv(seq, &mut q).ok_or("PKCS8 privateKey")?; + + let mut a = 0; + let (_, oid) = der_tlv(alg, &mut a).ok_or("PKCS8 OID")?; + let params = der_tlv(alg, &mut a).map(|(_, v)| v); + + if oid == OID_RSA { + parse_rsa(priv_octets) + } else if oid == OID_EC { + parse_ec(priv_octets, params) + } else if oid == OID_ED25519 || oid == OID_X25519 { + // CurvePrivateKey ::= OCTET STRING wrapping the 32-byte seed/scalar. + let mut s = 0; + let (_, seed) = der_tlv(priv_octets, &mut s).ok_or("Curve25519 key")?; + if seed.len() != 32 { + return Err("Curve25519 key must be 32 bytes".into()); + } + let (algo, tag) = if oid == OID_ED25519 { + (ALGO_ED25519, 0x07u32) + } else { + (ALGO_X25519, 0x08u32) + }; + let mut data = Vec::new(); + tlv::write(&mut data, tag, seed); + Ok((algo, data)) + } else { + Err("Unsupported key algorithm".into()) + } +} + +fn parse_rsa(der: &[u8]) -> Result<(u8, Vec), String> { + let mut p = 0; + let (_, seq) = der_tlv(der, &mut p).ok_or("RSA SEQUENCE")?; + let mut q = 0; + let mut ints: Vec<&[u8]> = Vec::new(); + while ints.len() < 6 { + match der_tlv(seq, &mut q) { + Some((0x02, v)) => ints.push(int_bytes(v)), + _ => break, + } + } + if ints.len() < 6 { + return Err("Not an RSA private key (need n,e,d,p,q)".into()); + } + // ints = [version, n, e, d, p, q] + let algo = match ints[1].len() { + 128 => ALGO_RSA1024, + 256 => ALGO_RSA2048, + 384 => ALGO_RSA3072, + 512 => ALGO_RSA4096, + _ => return Err("Unsupported RSA key size".into()), + }; + let mut data = Vec::new(); + tlv::write(&mut data, 0x01, ints[4]); // prime P + tlv::write(&mut data, 0x02, ints[5]); // prime Q + Ok((algo, data)) +} + +fn parse_ec(der: &[u8], pkcs8_params: Option<&[u8]>) -> Result<(u8, Vec), String> { + let mut p = 0; + let (_, seq) = der_tlv(der, &mut p).ok_or("EC SEQUENCE")?; + let mut q = 0; + der_tlv(seq, &mut q).ok_or("EC version")?; // version + let (_, scalar) = der_tlv(seq, &mut q).ok_or("EC privateKey")?; + + // Curve OID: from PKCS8 algorithm params, else the [0] tagged field in SEC1. + let curve = pkcs8_params + .and_then(|pp| { + let mut cp = 0; + der_tlv(pp, &mut cp).map(|(_, v)| v) + }) + .or_else(|| { + let mut r = q; + while let Some((tag, v)) = der_tlv(seq, &mut r) { + if tag == 0xA0 { + let mut cp = 0; + return der_tlv(v, &mut cp).map(|(_, o)| o); + } + } + None + }); + let (algo, field) = match curve { + Some(c) if c == OID_P256 => (ALGO_ECCP256, 32), + Some(c) if c == OID_P384 => (ALGO_ECCP384, 48), + _ => return Err("Unsupported EC curve (only P-256/P-384)".into()), + }; + let mut data = Vec::new(); + tlv::write(&mut data, 0x06, &pad_left(scalar, field)); + Ok((algo, data)) +} + +// ── Reset (block both, then factory reset) ────────────────────────────────── + +/// Upper bound on deliberate bad guesses when blocking a reference for reset. +/// SET PIN RETRIES accepts up to 255, so a fixed-10 loop failed to block a card +/// whose retry counter was raised above 10 (then RESET returns 6A80). +const PIN_BLOCK_MAX_TRIES: usize = 256; + +/// Factory-reset the PIV applet. The device only permits this once both PIN and +/// PUK are blocked, so this blocks them with deliberate bad guesses first. +pub fn reset(session: &CcidSession) -> Result<(), PFError> { + // Block PIN: wrong VERIFY until 0x6983. + for _ in 0..PIN_BLOCK_MAX_TRIES { + let bad = pad8(b"00000000"); + match session.transceive(&Apdu::write(CLA_ISO, INS_VERIFY, 0x00, REF_PIN, &bad)) { + Ok((_, sw)) if sw.0 == 0x6983 => break, + Ok(_) => continue, + Err(e) => return Err(e), + } + } + // Block PUK: wrong RESET RETRY until 0x6983. + for _ in 0..PIN_BLOCK_MAX_TRIES { + let mut bad = pad8(b"00000000").to_vec(); + bad.extend_from_slice(&pad8(b"00000000")); + match session.transceive(&Apdu::write(CLA_ISO, INS_RESET_RETRY, 0x00, REF_PIN, &bad)) { + Ok((_, sw)) if sw.0 == 0x6983 => break, + Ok(_) => continue, + Err(e) => return Err(e), + } + } + session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET, 0x00, 0x00, &[]))?; + Ok(()) +} + +// keep the chaining class referenced for the module's documented use. +const _: u8 = CLA_CHAIN; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cert_object_ids() { + assert_eq!(cert_object_id(SLOT_9A), [0x5F, 0xC1, 0x05]); + assert_eq!(cert_object_id(SLOT_9C), [0x5F, 0xC1, 0x0A]); + assert_eq!(cert_object_id(SLOT_9D), [0x5F, 0xC1, 0x0B]); + assert_eq!(cert_object_id(SLOT_9E), [0x5F, 0xC1, 0x01]); + assert_eq!(cert_object_id(0x82), [0x5F, 0xC1, 0x0D]); // retired 1 + } + + #[test] + fn parses_protected_mgm_key() { + // 53 { 88 { 89 <24-byte key> } } + let key = [0xABu8; 24]; + let mut inner = Vec::new(); + tlv::write(&mut inner, 0x89, &key); + let mut protected = Vec::new(); + tlv::write(&mut protected, 0x88, &inner); + let mut obj = Vec::new(); + tlv::write(&mut obj, 0x53, &protected); + assert_eq!(parse_protected_mgm(&obj), Some(key.to_vec())); + // Also accepts a bare (un-53-wrapped) body. + assert_eq!(parse_protected_mgm(&protected), Some(key.to_vec())); + // A 7-byte "key" is neither 16/24/32 → rejected. + assert_eq!(parse_protected_mgm(&[0x88, 0x09, 0x89, 0x07, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!(mgm_algo_for_len(16), ALGO_AES128); + assert_eq!(mgm_algo_for_len(24), ALGO_AES192); + assert_eq!(mgm_algo_for_len(32), ALGO_AES256); + } + + #[test] + fn change_ref_body_is_two_padded_blocks() { + // Standard 16-byte PIV CHANGE REFERENCE: current(8) ++ new(8), both + // 0xFF-padded. The current block MUST be a full 8 — the firmware splits + // the command at the stored length (always 8), so a 6-byte current would + // mis-verify and burn a retry. + let body = change_ref_body("123456", "87654321"); + assert_eq!(body.len(), 16); + assert_eq!(&body[..8], &[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF]); + assert_eq!(&body[8..], &[0x38, 0x37, 0x36, 0x35, 0x34, 0x33, 0x32, 0x31]); + } + + #[test] + fn generate_template_bytes() { + // AC { 80 01 11 AA 01 03 AB 01 02 } for P-256, PIN-always, touch-always. + let t = generate_template(ALGO_ECCP256, PIN_POLICY_ALWAYS, TOUCH_POLICY_ALWAYS); + assert_eq!(t, vec![0xAC, 0x09, 0x80, 0x01, 0x11, 0xAA, 0x01, 0x03, 0xAB, 0x01, 0x02]); + // Default policies are omitted. + let t2 = generate_template(ALGO_RSA2048, PIN_POLICY_DEFAULT, TOUCH_POLICY_DEFAULT); + assert_eq!(t2, vec![0xAC, 0x03, 0x80, 0x01, 0x07]); + } + + #[test] + fn cert_wrap_and_unwrap() { + let der = [0x30, 0x03, 0x01, 0x02, 0x03]; + let obj = wrap_cert_object(&der); + // 70 05 71 01 00 FE 00 + assert_eq!(&obj[..2], &[0x70, 0x05]); + assert_eq!(cert_der(&obj).unwrap(), der); + } + + #[test] + fn parses_metadata() { + // Slot meta: 01 01 11, 02 02 03 02, 03 01 01 + let mut m = Vec::new(); + tlv::write(&mut m, 0x01, &[ALGO_ECCP256]); + tlv::write(&mut m, 0x02, &[PIN_POLICY_ALWAYS, TOUCH_POLICY_ALWAYS]); + tlv::write(&mut m, 0x03, &[ORIGIN_GENERATED]); + let meta = parse_slot_meta(&m).unwrap(); + assert_eq!(meta.algo, ALGO_ECCP256); + assert_eq!(meta.pin_policy, PIN_POLICY_ALWAYS); + assert_eq!(meta.origin, ORIGIN_GENERATED); + + let mut r = Vec::new(); + tlv::write(&mut r, 0x05, &[0x00]); + tlv::write(&mut r, 0x06, &[3, 2]); + let rs = parse_ref_status(&r).unwrap(); + assert!(!rs.is_default); + assert_eq!((rs.total, rs.left), (3, 2)); + } + + #[test] + fn pin_padding() { + assert_eq!(pad8(b"123456"), [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF]); + assert_eq!(pad8(b"12345678"), [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38]); + } + + #[test] + fn parse_ed25519_pkcs8_rfc8410() { + // RFC 8410 §10.3 example Ed25519 private key (PEM decoded). + let seed = [ + 0xd4, 0xee, 0x72, 0xdb, 0xf9, 0x13, 0x58, 0x4a, 0xd5, 0xb6, 0xd8, 0xf1, 0xf7, 0x69, + 0xf8, 0xad, 0x3a, 0xfe, 0x7c, 0x28, 0xcb, 0xf1, 0xd4, 0xfb, 0xe0, 0x97, 0xa8, 0x8f, + 0x44, 0x75, 0x58, 0x42, + ]; + let mut der = vec![ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, + 0x04, 0x20, + ]; + der.extend_from_slice(&seed); + let (algo, data) = parse_pkcs8(&der).unwrap(); + assert_eq!(algo, ALGO_ED25519); + // IMPORT data = 07 20 + assert_eq!(data[0], 0x07); + assert_eq!(data[1], 0x20); + assert_eq!(&data[2..], &seed); + } + + #[test] + fn parse_rsa_pkcs1_extracts_primes() { + let mut inner = Vec::new(); + tlv::write(&mut inner, 0x02, &[0x00]); // version + tlv::write(&mut inner, 0x02, &[0x11; 128]); // n (128 B → RSA-1024) + tlv::write(&mut inner, 0x02, &[0x01, 0x00, 0x01]); // e + tlv::write(&mut inner, 0x02, &[0x11; 64]); // d + tlv::write(&mut inner, 0x02, &[0x22; 64]); // p + tlv::write(&mut inner, 0x02, &[0x33; 64]); // q + let mut der = Vec::new(); + tlv::write(&mut der, 0x30, &inner); + let (algo, data) = parse_rsa(&der).unwrap(); + assert_eq!(algo, ALGO_RSA1024); + // 01 40

02 40 + assert_eq!(&data[..2], &[0x01, 0x40]); + assert_eq!(&data[2..66], &[0x22; 64]); + assert_eq!(&data[66..68], &[0x02, 0x40]); + assert_eq!(&data[68..], &[0x33; 64]); + } + + #[test] + fn parse_ec_sec1_p256() { + let mut curve = Vec::new(); + tlv::write(&mut curve, 0x06, OID_P256); + let mut inner = Vec::new(); + tlv::write(&mut inner, 0x02, &[0x01]); // version + tlv::write(&mut inner, 0x04, &[0xAB; 32]); // scalar + tlv::write(&mut inner, 0xA0, &curve); // [0] curve params + let mut der = Vec::new(); + tlv::write(&mut der, 0x30, &inner); + let (algo, data) = parse_ec(&der, None).unwrap(); + assert_eq!(algo, ALGO_ECCP256); + assert_eq!(data[0], 0x06); // scalar tag + assert_eq!(data[1], 0x20); + assert_eq!(&data[2..], &[0xAB; 32]); + } + + #[test] + fn aes_ecb_fips197_vector() { + // FIPS-197 AES-128 ECB known-answer. + let key: [u8; 16] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, + ]; + let mut block: [u8; 16] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, + ]; + aes_ecb(&key, &mut block, true).unwrap(); + assert_eq!( + block, + [ + 0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, + 0xc5, 0x5a + ] + ); + aes_ecb(&key, &mut block, false).unwrap(); + assert_eq!(block[0], 0x00); + assert_eq!(block[15], 0xff); + } +} diff --git a/src/hal/fido/audit.rs b/src/hal/fido/audit.rs new file mode 100644 index 0000000..4263d21 --- /dev/null +++ b/src/hal/fido/audit.rs @@ -0,0 +1,260 @@ +//! Tamper-evident audit journal — parsing, hash-chain folding, and checkpoint +//! signature verification. +//! +//! The firmware keeps a flash ring of 20-byte security events, hash-chained from +//! an "epoch" accumulator that absorbs evicted history. A checkpoint is the chain +//! head signed with an ECDSA P-256 key derived from the device's OTP DEVK, over a +//! host-chosen challenge — so a caller can prove the log is authentic and that it +//! is talking to the enrolled device. These are pure functions (no transport), so +//! they are host-tested; the CBOR field extraction and I/O live in the parent. + +use ring::{digest, signature}; + +/// Bytes per journal entry on the wire. +pub const ENTRY_LEN: usize = 20; + +/// Domain-separation tag prefixing the signed checkpoint message. +const CKPT_TAG: &[u8] = b"RSK-AUDIT-CKPT-v1"; + +/// `EV_RESET` — the factory-reset event (offboard receipts require it present). +pub const EVT_RESET: u8 = 0x04; + +/// One decoded journal entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditEntry { + pub seq: u32, + pub uptime_ms: u32, + pub event: u8, + pub aux: u8, + pub detail: [u8; 8], +} + +impl AuditEntry { + pub fn event_label(&self) -> String { + match event_name(self.event) { + Some(name) => name.to_string(), + None => format!("0x{:02x}", self.event), + } + } + pub fn uptime_s(&self) -> f64 { + self.uptime_ms as f64 / 1000.0 + } + pub fn detail_hex(&self) -> String { + hex::encode(self.detail) + } +} + +/// A journal window plus its locally recomputed chain head. +#[derive(Debug, Clone)] +pub struct AuditJournal { + /// First live sequence number (`start` older entries are folded into epoch). + pub start: u32, + /// One past the last sequence number. + pub seq_next: u32, + pub epoch: [u8; 32], + /// Chain head folded over the exported window (`fold_chain`). + pub head: [u8; 32], + pub entries: Vec, +} + +/// Result of `audit verify`: the journal plus the checkpoint outcome. Also +/// serves the identity check (`inventory verify`) via `expected_match`. +#[derive(Debug, Clone)] +pub struct AuditVerification { + pub journal: AuditJournal, + /// The DEVK checkpoint signature verified against the returned public key. + pub signature_ok: bool, + /// The signed head equals the locally folded head (no read/sign race). + pub head_matches: bool, + pub pubkey_hex: String, + pub fingerprint: String, + pub seq_signed: u32, + /// The signed chain head (hex) and DER signature (hex) — for offboard receipts. + pub signed_head_hex: String, + pub signature_hex: String, + /// `Some(true/false)` when an expected key was supplied, else `None`. + pub expected_match: Option, +} + +impl AuditVerification { + /// Whether the journal is authentic: signature + head bind, and any pinned + /// key matches. + pub fn authentic(&self) -> bool { + self.signature_ok && self.head_matches && self.expected_match != Some(false) + } +} + +/// Human-readable name for an event id, or `None` for unknown ids. +pub fn event_name(event: u8) -> Option<&'static str> { + Some(match event { + 0x01 => "BOOT", + 0x02 => "MAKE_CREDENTIAL", + 0x03 => "GET_ASSERTION", + 0x04 => "RESET", + 0x05 => "PIN_SET", + 0x06 => "PIN_CHANGE", + 0x07 => "PIN_LOCKOUT", + 0x08 => "CFG_MIN_PIN", + 0x09 => "CFG_ENTERPRISE_ATT", + 0x0A => "LOCK_ENGAGE", + 0x0B => "LOCK_RELEASE", + 0x0C => "BACKUP_EXPORT", + 0x0D => "BACKUP_LOAD", + 0x0E => "BACKUP_FINALIZE", + 0x0F => "U2F_REGISTER", + 0x10 => "U2F_AUTH", + 0x11 => "CHECKPOINT", + 0x12 => "ATT_IMPORT", + 0x13 => "ATT_CLEAR", + 0x14 => "CFG_ALWAYS_UV", + 0x15 => "CONFIG_WRITE", + _ => return None, + }) +} + +/// Parse a concatenation of 20-byte entries. Trailing bytes shorter than an +/// entry are ignored. +pub fn parse_entries(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(ENTRY_LEN) + .map(|e| AuditEntry { + seq: u32::from_le_bytes([e[0], e[1], e[2], e[3]]), + uptime_ms: u32::from_le_bytes([e[4], e[5], e[6], e[7]]), + event: e[8], + aux: e[9], + detail: e[10..18].try_into().unwrap(), + }) + .collect() +} + +/// Fold the epoch accumulator over the window: `h = SHA256(h || entry)` per entry. +pub fn fold_chain(epoch: &[u8; 32], entries: &[u8]) -> [u8; 32] { + let mut h = *epoch; + for chunk in entries.chunks(ENTRY_LEN) { + let mut buf = [0u8; 32 + ENTRY_LEN]; + buf[..32].copy_from_slice(&h); + buf[32..32 + chunk.len()].copy_from_slice(chunk); + let d = digest::digest(&digest::SHA256, &buf[..32 + chunk.len()]); + h.copy_from_slice(d.as_ref()); + } + h +} + +/// The 16-hex-char attestation-key fingerprint (`sha256(pubkey)[..8]`). +pub fn fingerprint(pubkey: &[u8]) -> String { + let d = digest::digest(&digest::SHA256, pubkey); + hex::encode(&d.as_ref()[..8]) +} + +/// Verify the DEVK checkpoint signature over `head ‖ seq ‖ challenge`. +pub fn verify_checkpoint( + head: &[u8], + seq: u32, + sig: &[u8], + pubkey: &[u8], + challenge: &[u8], +) -> bool { + let mut msg = Vec::with_capacity(CKPT_TAG.len() + head.len() + 4 + challenge.len()); + msg.extend_from_slice(CKPT_TAG); + msg.extend_from_slice(head); + msg.extend_from_slice(&seq.to_le_bytes()); + msg.extend_from_slice(challenge); + let vk = signature::UnparsedPublicKey::new(&signature::ECDSA_P256_SHA256_ASN1, pubkey); + vk.verify(&msg, sig).is_ok() +} + +/// Assemble a journal window, checking its length matches `[start, seq_next)`. +pub fn build_journal( + start: u32, + seq_next: u32, + epoch: [u8; 32], + entries_bytes: &[u8], +) -> Result { + let expected = (seq_next.saturating_sub(start) as usize) * ENTRY_LEN; + if entries_bytes.len() % ENTRY_LEN != 0 || entries_bytes.len() != expected { + return Err("export length does not match the window — corrupt journal?".into()); + } + Ok(AuditJournal { + start, + seq_next, + epoch, + head: fold_chain(&epoch, entries_bytes), + entries: parse_entries(entries_bytes), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ring::rand::SystemRandom; + use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING}; + + fn entry(seq: u32, event: u8) -> Vec { + let mut e = vec![0u8; ENTRY_LEN]; + e[0..4].copy_from_slice(&seq.to_le_bytes()); + e[8] = event; + e + } + + #[test] + fn parses_and_labels_entries() { + let mut bytes = entry(5, 0x01); + bytes.extend(entry(6, 0xAB)); // unknown id + let parsed = parse_entries(&bytes); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].seq, 5); + assert_eq!(parsed[0].event_label(), "BOOT"); + assert_eq!(parsed[1].event_label(), "0xab"); + } + + #[test] + fn fold_is_deterministic_and_chained() { + let epoch = [7u8; 32]; + let mut w = entry(1, 0x01); + w.extend(entry(2, 0x03)); + let h1 = fold_chain(&epoch, &w); + // Folding one more entry must change the head (chain property). + let mut w2 = w.clone(); + w2.extend(entry(3, 0x04)); + assert_ne!(h1, fold_chain(&epoch, &w2)); + // Same input → same output. + assert_eq!(h1, fold_chain(&epoch, &w)); + } + + #[test] + fn build_journal_rejects_length_mismatch() { + assert!(build_journal(0, 3, [0u8; 32], &entry(0, 0x01)).is_err()); + let mut w = entry(0, 0x01); + w.extend(entry(1, 0x01)); + assert!(build_journal(0, 2, [0u8; 32], &w).is_ok()); + } + + #[test] + fn checkpoint_roundtrip_verifies_and_rejects_tamper() { + let rng = SystemRandom::new(); + let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap(); + let kp = + EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap(); + let pubkey = kp.public_key().as_ref().to_vec(); + + let head = [0x11u8; 32]; + let seq = 42u32; + let challenge = [0x22u8; 16]; + let mut msg = Vec::new(); + msg.extend_from_slice(CKPT_TAG); + msg.extend_from_slice(&head); + msg.extend_from_slice(&seq.to_le_bytes()); + msg.extend_from_slice(&challenge); + let sig = kp.sign(&rng, &msg).unwrap(); + + assert!(verify_checkpoint(&head, seq, sig.as_ref(), &pubkey, &challenge)); + // A different challenge must fail (freshness) and a bad key too. + assert!(!verify_checkpoint(&head, seq, sig.as_ref(), &pubkey, &[0x23u8; 16])); + assert!(!verify_checkpoint(&head, seq + 1, sig.as_ref(), &pubkey, &challenge)); + } + + #[test] + fn fingerprint_is_16_hex_chars() { + assert_eq!(fingerprint(&[0x04u8; 65]).len(), 16); + } +} diff --git a/src/hal/fido/backup.rs b/src/hal/fido/backup.rs new file mode 100644 index 0000000..249ae13 --- /dev/null +++ b/src/hal/fido/backup.rs @@ -0,0 +1,135 @@ +//! Wallet-style FIDO seed backup — the crypto half. +//! +//! The device exports its 32-byte master seed once, encrypted over an ephemeral +//! ECDH channel; restore re-seals a seed under the new device's key. Here live +//! the transport-independent pieces: the HKDF channel-key derivation, the +//! ChaCha20-Poly1305 seal/open, and the BIP-39 mnemonic rendering — all host- +//! tested. The ECDH handshake and vendor I/O live in the parent module. +//! +//! Only the classical P-256 channel is implemented; the firmware falls back to +//! it when the host offers no ML-KEM encapsulation key, so this stays +//! interoperable (just not post-quantum hybrid). + +use ring::aead; +use ring::hkdf; +use std::str::FromStr; + +/// Soft-lock / backup state reported by the vendor STATE subcommand. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackupStatus { + pub sealed: bool, + pub has_seed: bool, + pub locked: bool, + pub unlocked: bool, +} + +struct Len32; +impl hkdf::KeyType for Len32 { + fn len(&self) -> usize { + 32 + } +} + +/// Derive the 32-byte channel key: `HKDF-SHA256(salt="", ikm=z, info=aad)`. +/// `aad` is the device's uncompressed P-256 point (`0x04 ‖ x ‖ y`). +pub fn derive_channel_key(z: &[u8], aad: &[u8]) -> [u8; 32] { + let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, b"").extract(z); + let info: [&[u8]; 1] = [aad]; + let okm = prk.expand(&info, Len32).expect("hkdf expand"); + let mut key = [0u8; 32]; + okm.fill(&mut key).expect("hkdf fill"); + key +} + +/// Decrypt `nonce(12) ‖ ciphertext‖tag` bound to `aad`; returns the plaintext. +pub fn chacha_open(key: &[u8; 32], nonce_and_ct: &[u8], aad: &[u8]) -> Result, String> { + if nonce_and_ct.len() < 12 + 16 { + return Err("ciphertext too short".into()); + } + let (nonce_b, ct) = nonce_and_ct.split_at(12); + let ubk = + aead::UnboundKey::new(&aead::CHACHA20_POLY1305, key).map_err(|_| "bad key".to_string())?; + let lk = aead::LessSafeKey::new(ubk); + let nonce = aead::Nonce::assume_unique_for_key(nonce_b.try_into().unwrap()); + let mut buf = ct.to_vec(); + let pt = lk + .open_in_place(nonce, aead::Aad::from(aad), &mut buf) + .map_err(|_| "decryption failed (wrong channel / tampered)".to_string())?; + Ok(pt.to_vec()) +} + +/// Encrypt `plaintext` under `nonce`+`aad`; returns `nonce(12) ‖ ciphertext‖tag`. +pub fn chacha_seal( + key: &[u8; 32], + nonce_b: &[u8; 12], + plaintext: &[u8], + aad: &[u8], +) -> Result, String> { + let ubk = + aead::UnboundKey::new(&aead::CHACHA20_POLY1305, key).map_err(|_| "bad key".to_string())?; + let lk = aead::LessSafeKey::new(ubk); + let nonce = aead::Nonce::assume_unique_for_key(*nonce_b); + let mut buf = plaintext.to_vec(); + lk.seal_in_place_append_tag(nonce, aead::Aad::from(aad), &mut buf) + .map_err(|_| "encryption failed".to_string())?; + let mut blob = nonce_b.to_vec(); + blob.extend(buf); + Ok(blob) +} + +/// Render a 32-byte seed as a 24-word BIP-39 phrase. +pub fn seed_to_mnemonic(seed: &[u8; 32]) -> Result { + bip39::Mnemonic::from_entropy(seed) + .map(|m| m.to_string()) + .map_err(|e| e.to_string()) +} + +/// Parse a 24-word BIP-39 phrase back to its 32-byte seed. +pub fn mnemonic_to_seed(phrase: &str) -> Result<[u8; 32], String> { + let m = bip39::Mnemonic::from_str(phrase.trim()) + .map_err(|e| format!("invalid BIP-39 phrase: {e}"))?; + let (entropy, len) = m.to_entropy_array(); + if len != 32 { + return Err(format!("phrase encodes {len} bytes, expected 32")); + } + Ok(entropy[..32].try_into().unwrap()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn channel_key_is_deterministic_and_32_bytes() { + let z = [3u8; 32]; + let aad = [0x04u8; 65]; + let k1 = derive_channel_key(&z, &aad); + assert_eq!(k1, derive_channel_key(&z, &aad)); + // A different AAD must change the key. + let mut aad2 = aad; + aad2[1] ^= 1; + assert_ne!(k1, derive_channel_key(&z, &aad2)); + } + + #[test] + fn chacha_roundtrip_and_aad_binding() { + let key = [7u8; 32]; + let nonce = [9u8; 12]; + let aad = b"\x04aad-point"; + let seed = [0x42u8; 32]; + let blob = chacha_seal(&key, &nonce, &seed, aad).unwrap(); + assert_eq!(&blob[..12], &nonce); + assert_eq!(chacha_open(&key, &blob, aad).unwrap(), seed); + // Wrong AAD must fail closed. + assert!(chacha_open(&key, &blob, b"other").is_err()); + } + + #[test] + fn mnemonic_roundtrip_is_24_words() { + let seed: [u8; 32] = std::array::from_fn(|i| i as u8); + let phrase = seed_to_mnemonic(&seed).unwrap(); + assert_eq!(phrase.split_whitespace().count(), 24); + assert_eq!(mnemonic_to_seed(&phrase).unwrap(), seed); + assert!(mnemonic_to_seed("not a valid phrase").is_err()); + } +} diff --git a/src/hal/fido/constants.rs b/src/hal/fido/constants.rs index 2508bf7..96b0113 100644 --- a/src/hal/fido/constants.rs +++ b/src/hal/fido/constants.rs @@ -729,6 +729,44 @@ pub const RSKEY_CONFIG_READ: u8 = 0x0D; /// as CONFIG_READ. Requires ACFG-gated PIN token. pub const RSKEY_CONFIG_WRITE: u8 = 0x0C; +// RS-Key 0x41 vendor subcommands for seed backup, soft-lock, and the audit +// journal. Backup export/load and audit read/checkpoint are ACFG-gated +// (PIN token or, absent a PIN, a physical touch); MSE / STATE / UNLOCK are not. +/// MSE — ephemeral-ECDH channel setup for backup export/restore and unlock. +pub const RSKEY_VENDOR_MSE: u8 = 1; +/// EXPORT — read the encrypted master seed (one-time window). +pub const RSKEY_VENDOR_EXPORT: u8 = 2; +/// LOAD — restore a seed from a backup. +pub const RSKEY_VENDOR_LOAD: u8 = 3; +/// FINALIZE — seal the one-time export window. +pub const RSKEY_VENDOR_FINALIZE: u8 = 4; +/// STATE — `{1: sealed, 2: has_seed, 3: locked, 4: unlocked}`. +pub const RSKEY_VENDOR_STATE: u8 = 5; +/// UNLOCK — load a soft-locked seed into RAM for this power cycle. +pub const RSKEY_VENDOR_UNLOCK: u8 = 6; +/// AUDIT_READ — export the journal window. +pub const RSKEY_VENDOR_AUDIT_READ: u8 = 7; +/// AUDIT_CHECKPOINT — sign the chain head over a host challenge. +pub const RSKEY_VENDOR_AUDIT_CHECKPOINT: u8 = 8; +/// ATT_IMPORT — install an org attestation P-256 key + cert chain (MSE-wrapped). +pub const RSKEY_VENDOR_ATT_IMPORT: u8 = 9; +/// ATT_CLEAR — remove the org attestation (back to the self-signed cert). +pub const RSKEY_VENDOR_ATT_CLEAR: u8 = 10; +/// ATT_STATE — `{1: installed, 2: chain_hash}`. +pub const RSKEY_VENDOR_ATT_STATE: u8 = 11; +/// AUDIT_CONFIG — turn the audit journal on/off. `subCommandParams` key 1 is the +/// target: `0` = disable, `1` = enable (both PIN + touch gated), `2` = read-only +/// status (ungated). The response `{1: bool}` is the resulting on/off state. +pub const RSKEY_VENDOR_AUDIT_CONFIG: u8 = 14; + +// authenticatorConfig VendorPrototype (0xFF) 64-bit IDs for the at-rest soft +// lock: they wrap / restore the FIDO seed and so run over authenticatorConfig +// (PIN required), not the 0x41 channel. +/// AUT_ENABLE — wrap the seed under a host lock key and erase the plaintext. +pub const RSKEY_AUT_ENABLE: u64 = 0x03E4_3F56_B342_85E2; +/// AUT_DISABLE — restore the plaintext seed (requires a prior unlock). +pub const RSKEY_AUT_DISABLE: u64 = 0x1831_A40F_04A2_5ED9; + /// RS-Key config target: device configuration (VID, PID, serial, product name). pub const RSKEY_CFG_TARGET_DEV_CONF: u8 = 0x00; /// RS-Key config target: physical config (LED GPIO, brightness, options). diff --git a/src/hal/fido/mod.rs b/src/hal/fido/mod.rs index 71d1a71..70bc7ba 100644 --- a/src/hal/fido/mod.rs +++ b/src/hal/fido/mod.rs @@ -54,6 +54,8 @@ //! open transport → build CBOR payload → send → parse response → return. //! 4. Expose it through [`super::io`]. +pub mod audit; +pub mod backup; pub mod constants; pub mod ops; use crate::hal::transport::fido::{CTAPHID_CBOR, HidTransport}; @@ -92,6 +94,18 @@ const RSKEY_PHY_TAG_ENABLED_USB_ITF: u8 = 0x0B; const RSKEY_PHY_TAG_LED_DRIVER: u8 = 0x0C; const RSKEY_PHY_TAG_LED_ORDER: u8 = 0x0D; const RSKEY_PHY_TAG_LED_NUM: u8 = 0x0E; +const RSKEY_PHY_TAG_USB_MANUFACTURER: u8 = 0x0F; + +/// The boot-resolved *effective* phy values a device reports in CONFIG_READ key 2 +/// (build defaults or overrides) — used to show real numbers instead of a bare +/// "firmware default". All `None` on firmware that predates the field / a headless +/// build. +#[derive(Default, Clone, Copy)] +pub struct EffectivePhy { + pub led_gpio: Option, + pub led_driver: Option, + pub touch_timeout: Option, +} const RSKEY_OPT_DIMMABLE: u16 = 0x02; const RSKEY_OPT_DISABLE_POWER_RESET: u16 = 0x04; @@ -651,6 +665,9 @@ pub fn read_device_details() -> Result { vid: format!("{:04X}", transport.vid), pid: format!("{:04X}", transport.pid), product_name: transport.product_name.clone(), + // The effective iManufacturer string, so the field mirrors Product Name; + // a phy 0x0F override (read below) then wins if one is set. + manufacturer_name: transport.manufacturer.clone().unwrap_or_default(), ..Default::default() }; let config = if firmware_type == FirmwareType::RSKey { @@ -691,6 +708,11 @@ pub fn read_device_details() -> Result { flash_used: mem_stats.map(|(used, _)| used / 1024), flash_total: mem_stats.map(|(_, total)| total / 1024), firmware_version, + bcd_device: Some(transport.release_number), + manufacturer: transport.manufacturer.clone(), + // Object count / chip size come from the Rescue FlashInfo, not FIDO. + flash_files: None, + flash_chip_size: None, }, config, secure_boot: false, @@ -774,7 +796,11 @@ fn parse_management_info(raw: &[u8]) -> Result { match tag { 0x01 => info.usb_supported = parse_management_u16(field_data), 0x02 if field_data.len() == 4 => { - info.serial = Some(hex::encode_upper(field_data)); + // TAG_SERIAL is the 8-digit Yubico decimal (already serial4-masked + // by the firmware), big-endian — render it as ykman does, not hex. + let n = + u32::from_be_bytes([field_data[0], field_data[1], field_data[2], field_data[3]]); + info.serial = Some(n.to_string()); } 0x03 => info.usb_enabled = parse_management_u16(field_data), 0x05 if field_data.len() >= 2 => { @@ -893,10 +919,13 @@ fn read_legacy_physical_config(transport: &HidTransport, mut config: AppConfig) fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) -> AppConfig { // `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 { + let Ok((data, effective)) = 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; }; + config.effective_led_gpio = effective.led_gpio; + config.effective_led_driver = effective.led_driver; + config.effective_touch_timeout = effective.touch_timeout; let data = &data[..]; let mut i = 0; @@ -932,6 +961,12 @@ fn read_rskey_physical_config(transport: &HidTransport, mut config: AppConfig) - .trim_matches(char::from(0)); config.product_name = product_str.to_string(); } + RSKEY_PHY_TAG_USB_MANUFACTURER => { + let mfr = std::str::from_utf8(field_data) + .unwrap_or("") + .trim_matches(char::from(0)); + config.manufacturer_name = mfr.to_string(); + } RSKEY_PHY_TAG_OPTS if field_data.len() >= 2 => { let opts = u16::from_be_bytes([field_data[0], field_data[1]]); config.led_dimmable = opts & RSKEY_OPT_DIMMABLE != 0; @@ -1041,6 +1076,19 @@ fn build_rskey_phy_tlv(config: &AppConfigInput) -> Result, PFError> { tlv.push(0x00); } + if let Some(mfr) = config.manufacturer_name.as_deref().filter(|n| !n.is_empty()) { + let bytes = mfr.as_bytes(); + if bytes.len() + 1 > 33 { + return Err(PFError::Device( + "Manufacturer name too long (max 32 bytes).".into(), + )); + } + tlv.push(RSKEY_PHY_TAG_USB_MANUFACTURER); + tlv.push((bytes.len() + 1) as u8); + tlv.extend_from_slice(bytes); + tlv.push(0x00); + } + if config.enable_secp256k1.is_some() || config.raw_curves_mask.is_some() { let mut mask = config.raw_curves_mask.unwrap_or(0); if let Some(enabled) = config.enable_secp256k1 { @@ -1120,10 +1168,15 @@ fn write_rskey_config( transport.rs_key_config_write(&pin_token, RSKEY_CFG_TARGET_PHY, &tlv)?; - Ok( + // RS-Key v0.3.x (bcd 0x083A+) warm-reboots and re-enumerates itself after a + // PHY write, unless the user disabled power-cycle-on-reset (OPT_DISABLE_ + // POWER_RESET), in which case a manual re-plug is still required. + let msg = if config.power_cycle_on_reset == Some(false) { "Configuration updated successfully! Unplug and re-plug the device to apply changes." - .to_string(), - ) + } else { + "Configuration updated successfully! The device is re-enumerating to apply the changes." + }; + Ok(msg.to_string()) } /// Write device configuration over the FIDO HID transport. @@ -1189,6 +1242,7 @@ fn is_empty_config_input(config: &AppConfigInput) -> bool { config.vid.is_none() && config.pid.is_none() && config.product_name.is_none() + && config.manufacturer_name.is_none() && config.led_gpio.is_none() && config.led_brightness.is_none() && config.touch_timeout.is_none() @@ -1215,6 +1269,7 @@ fn validate_fido_config_changes( if config.vid.is_some() || config.pid.is_some() || config.product_name.is_some() + || config.manufacturer_name.is_some() || config.led_gpio.is_some() || config.led_brightness.is_some() || config.touch_timeout.is_some() @@ -1452,6 +1507,481 @@ pub(crate) fn get_enterprise_attestation_csr() -> Result { Ok(pem) } +// ── RS-Key audit journal (0x41 vendor AUDIT_READ / AUDIT_CHECKPOINT) ───── + +/// Map a non-zero CTAP status from a gated vendor command to a message. +fn vendor_error(status: u8, op: &str) -> String { + match status { + 0x36 => "device requires a PIN — enter it".to_string(), + 0x27 => "denied — no touch within the window (press the button when the LED blinks)".to_string(), + 0x30 => format!("{op}: operation not allowed (already sealed, or no OTP DEVK provisioned)"), + 0x3D => "device is not locked".to_string(), + other => format!("{op} failed: status 0x{other:02x}"), + } +} + +/// Require a successful vendor response and unwrap its CBOR map. +fn vendor_map( + status: u8, + map: Option, + op: &str, +) -> Result, String> { + if status != 0 { + return Err(vendor_error(status, op)); + } + match map { + Some(Value::Map(m)) => Ok(m), + _ => Err(format!("{op}: response is not a CBOR map")), + } +} + +fn m_int(m: &BTreeMap, k: i128) -> Option { + match m.get(&Value::Integer(k)) { + Some(Value::Integer(n)) => Some(*n), + _ => None, + } +} + +fn m_bytes(m: &BTreeMap, k: i128) -> Option> { + match m.get(&Value::Integer(k)) { + Some(Value::Bytes(b)) => Some(b.clone()), + _ => None, + } +} + +/// Coerce a CBOR field to bool (accepts a CBOR bool or a non-zero integer). +fn m_bool(m: &BTreeMap, k: i128) -> bool { + match m.get(&Value::Integer(k)) { + Some(Value::Bool(b)) => *b, + Some(Value::Integer(n)) => *n != 0, + _ => false, + } +} + +/// Read AUDIT_READ into a journal window (gated by PIN, or a touch if `pin` is None). +fn read_journal(transport: &HidTransport, pin: Option<&str>) -> Result { + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_AUDIT_READ, None, pin) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "audit read")?; + let start = m_int(&m, 1).ok_or("audit read: missing start")? as u32; + let seq_next = m_int(&m, 2).ok_or("audit read: missing seq_next")? as u32; + let epoch: [u8; 32] = m_bytes(&m, 3) + .ok_or("audit read: missing epoch")? + .try_into() + .map_err(|_| "audit read: epoch is not 32 bytes")?; + let entries = m_bytes(&m, 4).ok_or("audit read: missing entries")?; + audit::build_journal(start, seq_next, epoch, &entries) +} + +/// Export the audit journal (PIN or touch gated). +pub(crate) fn audit_log(pin: Option) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + read_journal(&transport, pin.as_deref()) +} + +/// Export the journal, then verify a fresh DEVK-signed checkpoint over it. +/// `expect_key` (16-hex fingerprint or full SEC1 pubkey hex) pins the identity. +pub(crate) fn audit_verify( + pin: Option, + expect_key: Option, +) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let journal = read_journal(&transport, pin.as_deref())?; + + let mut challenge = [0u8; 16]; + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut challenge) + .map_err(|_| "RNG failure".to_string())?; + + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Bytes(challenge.to_vec())); + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_AUDIT_CHECKPOINT, Some(Value::Map(params)), pin.as_deref()) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "checkpoint")?; + + let head_signed = m_bytes(&m, 1).ok_or("checkpoint: missing head")?; + let seq_signed = m_int(&m, 2).ok_or("checkpoint: missing seq")? as u32; + let sig = m_bytes(&m, 3).ok_or("checkpoint: missing signature")?; + let pubkey = m_bytes(&m, 4).ok_or("checkpoint: missing public key")?; + + let signature_ok = audit::verify_checkpoint(&head_signed, seq_signed, &sig, &pubkey, &challenge); + let head_matches = head_signed == journal.head; + let fingerprint = audit::fingerprint(&pubkey); + let pubkey_hex = hex::encode(&pubkey); + let expected_match = expect_key.map(|k| { + let k = k.trim().to_lowercase(); + !k.is_empty() && (k == pubkey_hex || k == fingerprint) + }); + + Ok(audit::AuditVerification { + journal, + signature_ok, + head_matches, + pubkey_hex, + fingerprint, + seq_signed, + signed_head_hex: hex::encode(&head_signed), + signature_hex: hex::encode(&sig), + expected_match, + }) +} + +/// Whether the audit journal is currently on (ungated status query, no touch). +pub(crate) fn audit_status() -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Integer(2)); // target 2 = read-only status + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_AUDIT_CONFIG, Some(Value::Map(params)), None) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "audit status")?; + Ok(m_bool(&m, 1)) +} + +/// Turn the audit journal on/off (PIN + touch); returns the resulting state. +/// Journalling is opt-in, so nothing is written to flash until it is enabled. +pub(crate) fn audit_set_enabled(on: bool, pin: Option) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Integer(if on { 1 } else { 0 })); + let (status, map) = transport + .rs_key_vendor( + RSKEY_VENDOR_AUDIT_CONFIG, + Some(Value::Map(params)), + pin.as_deref(), + ) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "audit config")?; + Ok(m_bool(&m, 1)) +} + +// ── RS-Key seed backup (0x41 vendor MSE / EXPORT / LOAD / FINALIZE / STATE) ── + +/// Ephemeral ECDH (classical P-256) handshake → `(channel_key, aad)`. +/// +/// Sends the host's P-256 public point as a COSE key; the device replies with +/// its point, and both sides derive the same ChaCha20-Poly1305 key. The device +/// falls back to this classical channel when no ML-KEM key is offered. +fn mse_handshake(transport: &HidTransport) -> Result<([u8; 32], Vec), String> { + use ring::{agreement, rand::SystemRandom}; + + let rng = SystemRandom::new(); + let priv_key = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng) + .map_err(|_| "ECDH keygen failed".to_string())?; + let pub_bytes = priv_key + .compute_public_key() + .map_err(|_| "ECDH pubkey failed".to_string())?; + let pk = pub_bytes.as_ref(); + if pk.len() != 65 { + return Err("unexpected ECDH public key length".into()); + } + + // COSE_Key {1: EC2, 3: ECDH-ES+HKDF-256, -1: P-256, -2: x, -3: y}. + let mut cose = BTreeMap::new(); + cose.insert(Value::Integer(1), Value::Integer(2)); + cose.insert(Value::Integer(3), Value::Integer(-25)); + cose.insert(Value::Integer(-1), Value::Integer(1)); + cose.insert(Value::Integer(-2), Value::Bytes(pk[1..33].to_vec())); + cose.insert(Value::Integer(-3), Value::Bytes(pk[33..65].to_vec())); + let mut subpara = BTreeMap::new(); + subpara.insert(Value::Integer(1), Value::Map(cose)); + + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_MSE, Some(Value::Map(subpara)), None) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "MSE")?; + + let dev = match m.get(&Value::Integer(1)) { + Some(Value::Map(dm)) => dm, + _ => return Err("MSE: no device key in response".into()), + }; + let dx = m_bytes(dev, -2).ok_or("MSE: device key missing x")?; + let dy = m_bytes(dev, -3).ok_or("MSE: device key missing y")?; + + let mut peer = Vec::with_capacity(65); + peer.push(0x04); + peer.extend_from_slice(&dx); + peer.extend_from_slice(&dy); + let aad = peer.clone(); // AAD = the device's uncompressed point. + + let peer_pub = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, &peer); + let aad_kdf = aad.clone(); + let key = agreement::agree_ephemeral(priv_key, &peer_pub, |z| { + backup::derive_channel_key(z, &aad_kdf) + }) + .map_err(|_| "ECDH agreement failed".to_string())?; + + Ok((key, aad)) +} + +/// Read `{sealed, has_seed, locked, unlocked}` (ungated). +pub(crate) fn backup_status() -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_STATE, None, None) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "state")?; + Ok(backup::BackupStatus { + sealed: m_bool(&m, 1), + has_seed: m_bool(&m, 2), + locked: m_bool(&m, 3), + unlocked: m_bool(&m, 4), + }) +} + +/// Seal the one-time export window (touch-gated). +pub(crate) fn backup_finalize() -> Result<(), String> { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let (status, _) = transport + .rs_key_vendor(RSKEY_VENDOR_FINALIZE, None, None) + .map_err(|e| e.to_string())?; + if status != 0 { + return Err(vendor_error(status, "finalize")); + } + Ok(()) +} + +/// Export the master seed as a 24-word BIP-39 phrase (PIN or touch gated). +pub(crate) fn backup_export(pin: Option) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let (key, aad) = mse_handshake(&transport)?; + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_EXPORT, None, pin.as_deref()) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "export (already sealed?)")?; + let enc = m_bytes(&m, 1).ok_or("export: missing sealed seed")?; + let seed = backup::chacha_open(&key, &enc, &aad)?; + let seed: [u8; 32] = seed + .try_into() + .map_err(|_| "exported seed is not 32 bytes".to_string())?; + backup::seed_to_mnemonic(&seed) +} + +/// Restore a seed from a 24-word BIP-39 phrase (PIN or touch gated). +pub(crate) fn backup_restore(pin: Option, mnemonic: String) -> Result<(), String> { + let seed = backup::mnemonic_to_seed(&mnemonic)?; + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let (key, aad) = mse_handshake(&transport)?; + + let mut nonce = [0u8; 12]; + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce) + .map_err(|_| "RNG failure".to_string())?; + let blob = backup::chacha_seal(&key, &nonce, &seed, &aad)?; + + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Bytes(blob)); + let (status, _) = transport + .rs_key_vendor(RSKEY_VENDOR_LOAD, Some(Value::Map(params)), pin.as_deref()) + .map_err(|e| e.to_string())?; + if status != 0 { + return Err(vendor_error(status, "restore")); + } + Ok(()) +} + +// ── RS-Key at-rest soft lock (0x41 UNLOCK + authConfig AUT_ENABLE/DISABLE) ── + +/// MSE-wrap a 32-byte secret for the vendor channel: `nonce(12) ‖ ct‖tag`. +fn wrap_secret(transport: &HidTransport, secret: &[u8; 32]) -> Result, String> { + let (key, aad) = mse_handshake(transport)?; + let mut nonce = [0u8; 12]; + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce) + .map_err(|_| "RNG failure".to_string())?; + backup::chacha_seal(&key, &nonce, secret, &aad) +} + +/// Load the soft-locked seed into RAM for this power cycle (the lock key comes +/// as a BIP-39 phrase). Ungated over the 0x41 channel. +pub(crate) fn lock_unlock(mnemonic: String) -> Result<(), String> { + let key = backup::mnemonic_to_seed(&mnemonic)?; + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let blob = wrap_secret(&transport, &key)?; + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Bytes(blob)); + let (status, _) = transport + .rs_key_vendor(RSKEY_VENDOR_UNLOCK, Some(Value::Map(params)), None) + .map_err(|e| e.to_string())?; + if status == 0x3D { + return Err("device is not locked".into()); + } + if status != 0 { + return Err(vendor_error(status, "unlock (wrong key?)")); + } + Ok(()) +} + +/// Engage the lock: generate a random 32-byte key, wrap the seed under it, erase +/// the plaintext, and return the key as a 24-word phrase to record. PIN required. +pub(crate) fn lock_enable(pin: String) -> Result { + let mut key = [0u8; 32]; + ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut key) + .map_err(|_| "RNG failure".to_string())?; + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let blob = wrap_secret(&transport, &key)?; + let token = transport + .get_pin_token_with_permission( + &pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + ) + .map_err(|e| e.to_string())?; + transport + .authconfig_vendor(&token, RSKEY_AUT_ENABLE, Some(Value::Bytes(blob))) + .map_err(|e| format!("engage lock failed: {e}"))?; + backup::seed_to_mnemonic(&key) +} + +/// Disable the lock: unlock with the phrase, then restore the plaintext seed. +/// PIN required. +pub(crate) fn lock_disable(pin: String, mnemonic: String) -> Result<(), String> { + let key = backup::mnemonic_to_seed(&mnemonic)?; + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + + // Unlock first (loads the seed into RAM); tolerate "already unlocked". + let blob = wrap_secret(&transport, &key)?; + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Bytes(blob)); + let (status, _) = transport + .rs_key_vendor(RSKEY_VENDOR_UNLOCK, Some(Value::Map(params)), None) + .map_err(|e| e.to_string())?; + if status != 0 && status != 0x3D { + return Err(vendor_error(status, "unlock (wrong key?)")); + } + + let token = transport + .get_pin_token_with_permission( + &pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + ) + .map_err(|e| e.to_string())?; + transport + .authconfig_vendor(&token, RSKEY_AUT_DISABLE, None) + .map_err(|e| format!("disable lock failed: {e}")) +} + +// ── RS-Key org attestation (0x41 vendor ATT_STATE / ATT_CLEAR / ATT_IMPORT) ── + +/// Org-attestation state. +#[derive(Debug, Clone)] +pub struct AttStatus { + pub installed: bool, + pub chain_hash: Option, +} + +/// Read `{installed, chain_hash}` (ungated). +pub(crate) fn att_status() -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let (status, map) = transport + .rs_key_vendor(RSKEY_VENDOR_ATT_STATE, None, None) + .map_err(|e| e.to_string())?; + let m = vendor_map(status, map, "attestation status")?; + let installed = m_bool(&m, 1); + let chain_hash = if installed { + m_bytes(&m, 2).map(hex::encode) + } else { + None + }; + Ok(AttStatus { + installed, + chain_hash, + }) +} + +/// Remove the org attestation (needs an MSE channel, then PIN/touch). +pub(crate) fn att_clear(pin: Option) -> Result<(), String> { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + mse_handshake(&transport)?; + let (status, _) = transport + .rs_key_vendor(RSKEY_VENDOR_ATT_CLEAR, None, pin.as_deref()) + .map_err(|e| e.to_string())?; + if status != 0 { + return Err(vendor_error(status, "attestation clear")); + } + Ok(()) +} + +/// Concatenate the DER of every PEM certificate, or pass raw DER through. +fn certs_pem_to_der(input: &[u8]) -> Result, String> { + let text = std::str::from_utf8(input).unwrap_or(""); + if !text.contains("-----BEGIN CERTIFICATE-----") { + return if input.first() == Some(&0x30) { + Ok(input.to_vec()) + } else { + Err("chain is neither PEM nor DER".into()) + }; + } + use base64::{engine::general_purpose, Engine as _}; + let mut out = Vec::new(); + let mut rest = text; + while let Some(b) = rest.find("-----BEGIN CERTIFICATE-----") { + let after = &rest[b + 27..]; + let e = after.find("-----END").ok_or("truncated PEM certificate")?; + let b64: String = after[..e].chars().filter(|c| !c.is_whitespace()).collect(); + let der = general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|_| "bad base64 in certificate".to_string())?; + out.extend(der); + rest = &after[e..]; + } + Ok(out) +} + +/// Install an org attestation P-256 key (PEM/DER) + cert chain (PEM/DER). +pub(crate) fn att_import( + pin: Option, + key_file: Vec, + chain_file: Vec, +) -> Result<(), String> { + use crate::hal::applets::piv; + + let (algo, material) = piv::parse_private_key(&key_file)?; + if algo != piv::ALGO_ECCP256 { + return Err("attestation key must be P-256".into()); + } + // material = `06 20 <32-byte scalar>`; take the value. + if material.len() < 2 + 32 { + return Err("could not read the P-256 scalar".into()); + } + let scalar: [u8; 32] = material[2..2 + 32] + .try_into() + .map_err(|_| "P-256 scalar is not 32 bytes".to_string())?; + + let chain = certs_pem_to_der(&chain_file)?; + if chain.is_empty() || chain.len() > 2048 { + return Err(format!("cert chain must be 1..=2048 bytes (got {})", chain.len())); + } + + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let blob = wrap_secret(&transport, &scalar)?; + + let mut params = BTreeMap::new(); + params.insert(Value::Integer(1), Value::Bytes(blob)); + params.insert(Value::Integer(2), Value::Bytes(chain)); + let (status, _) = transport + .rs_key_vendor(RSKEY_VENDOR_ATT_IMPORT, Some(Value::Map(params)), pin.as_deref()) + .map_err(|e| e.to_string())?; + if status != 0 { + return Err(vendor_error(status, "attestation import")); + } + Ok(()) +} + // ── RS-Key FIDO LED config (CONFIG_READ/WRITE target 0x02) ────────────── /// RS-Key LED config block length: `[steady(1), (effect, color, brightness, speed) × 4]` @@ -1467,7 +1997,7 @@ const RSKEY_LED_CONF_LEN: usize = 17; pub(crate) fn read_rskey_led_config(transport: &HidTransport) -> Result { // `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 (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", @@ -1499,7 +2029,7 @@ pub(crate) fn write_rskey_led_config( // + 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) + if let Ok((current, _)) = transport.rs_key_config_read(RSKEY_CFG_TARGET_LED) && current.len() >= RSKEY_LED_CONF_LEN { block.copy_from_slice(¤t[..RSKEY_LED_CONF_LEN]); @@ -1569,6 +2099,7 @@ mod tests { vid: None, pid: None, product_name: None, + manufacturer_name: None, led_gpio: None, led_brightness: None, touch_timeout: None, @@ -1694,7 +2225,7 @@ mod tests { fn test_parse_management_info_length_prefixed_tlv() { let tlv = vec![ 0x01, 0x02, 0x02, 0x23, // TAG_USB_SUPPORTED - 0x02, 0x04, 0x12, 0x34, 0x56, 0x78, // TAG_SERIAL + 0x02, 0x04, 0x02, 0x39, 0x2F, 0x25, // TAG_SERIAL (big-endian 0x02392F25) 0x03, 0x01, 0x03, // TAG_USB_ENABLED 0x05, 0x03, 0x07, 0x06, 0x00, // TAG_VERSION 0x0A, 0x01, 0x01, // TAG_CONFIG_LOCK @@ -1705,7 +2236,8 @@ mod tests { let info = parse_management_info(&raw).unwrap(); assert_eq!(info.usb_supported, Some(0x0223)); - assert_eq!(info.serial.as_deref(), Some("12345678")); + // TAG_SERIAL renders as the big-endian decimal (0x02392F25 = 37302053). + assert_eq!(info.serial.as_deref(), Some("37302053")); assert_eq!(info.usb_enabled, Some(0x0003)); assert_eq!(info.firmware_version.as_deref(), Some("7.6")); assert_eq!(info.config_locked, Some(true)); diff --git a/src/hal/fido/ops.rs b/src/hal/fido/ops.rs index 2cc0d41..d4d8236 100644 --- a/src/hal/fido/ops.rs +++ b/src/hal/fido/ops.rs @@ -120,10 +120,26 @@ pub trait FidoOperations { credential_id_map: Value, ) -> Result<(), PFError>; /// Read RS-Key configuration via the 0x41 CONFIG_READ vendor command. - fn rs_key_config_read(&self, target: u8) -> Result, PFError>; + fn rs_key_config_read(&self, target: u8) -> Result<(Vec, super::EffectivePhy), PFError>; /// Write RS-Key configuration via the 0x41 CONFIG_WRITE vendor command. fn rs_key_config_write(&self, pin_token: &[u8], target: u8, blob: &[u8]) -> Result<(), PFError>; + /// Send an RS-Key 0x41 vendor subcommand (backup / audit / soft-lock) and + /// return `(ctap_status, response_map)`. + fn rs_key_vendor( + &self, + sub_cmd: u8, + params: Option, + pin: Option<&str>, + ) -> Result<(u8, Option), PFError>; + /// Invoke an authenticatorConfig VendorPrototype (0xFF) command by 64-bit id + /// with an optional parameter, authorised by a PERM_ACFG `pin_token`. + fn authconfig_vendor( + &self, + pin_token: &[u8], + vendor_id: u64, + param: Option, + ) -> Result<(), PFError>; /// Compute a pinUvAuthToken signature for a credential management sub-command. fn sign_credential_mgmt_command( &self, @@ -1442,7 +1458,7 @@ impl FidoOperations for HidTransport { /// 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, PFError> { + fn rs_key_config_read(&self, target: u8) -> Result<(Vec, super::EffectivePhy), PFError> { let mut params = BTreeMap::new(); params.insert(Value::Integer(1), Value::Integer(RSKEY_CONFIG_READ as i128)); @@ -1456,14 +1472,31 @@ impl FidoOperations for HidTransport { full_payload.extend(inner); let resp = self.send_cbor(CTAPHID_CBOR, &full_payload)?; - // Response is CBOR `{1: blob(bstr)}` — unwrap key 1 to the raw record. + // Response `{1: blob(bstr), 2: {phy_tag: value}}`: key 1 is the raw record; + // key 2 (RS-Key 0x0852+) is the boot-resolved effective LED pin (tag 4) / + // driver (tag 12) / touch timeout (tag 8). Key 2 is optional. match from_slice::(&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(), - )), - }, + Ok(Value::Map(m)) => { + let blob = match m.get(&Value::Integer(1)) { + Some(Value::Bytes(b)) => b.clone(), + _ => { + return Err(PFError::Device( + "CONFIG_READ response missing blob (key 1)".into(), + )); + } + }; + let mut eff = super::EffectivePhy::default(); + if let Some(Value::Map(e)) = m.get(&Value::Integer(2)) { + let byte = |k: i128| match e.get(&Value::Integer(k)) { + Some(Value::Integer(n)) if (0..=255).contains(n) => Some(*n as u8), + _ => None, + }; + eff.led_gpio = byte(4); + eff.led_driver = byte(12); + eff.touch_timeout = byte(8); + } + Ok((blob, eff)) + } _ => Err(PFError::Device( "CONFIG_READ response is not a CBOR map".into(), )), @@ -1520,6 +1553,104 @@ impl FidoOperations for HidTransport { .map(|_| ()) } + fn rs_key_vendor( + &self, + sub_cmd: u8, + params: Option, + pin: Option<&str>, + ) -> Result<(u8, Option), PFError> { + let params_bytes = match ¶ms { + Some(v) => to_vec(v).map_err(|e| PFError::Io(e.to_string()))?, + None => Vec::new(), + }; + + let mut outer = BTreeMap::new(); + outer.insert(Value::Integer(1), Value::Integer(sub_cmd as i128)); + if let Some(v) = params { + outer.insert(Value::Integer(2), v); + } + // With a PIN, authorise via a PERM_ACFG token + MAC — the proven + // CONFIG_WRITE path (protocol 1, 16-byte tag). Without one, the firmware + // gates on a physical touch instead, so no auth fields are sent. + if let Some(pin) = pin { + let token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, + None, + )?; + let mut input = vec![0xFFu8; 32]; + input.push(RSKEY_CTAPHID_VENDOR_CMD); + input.push(sub_cmd); + input.extend(¶ms_bytes); + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, &token); + let mac = hmac::sign(&hmac_key, &input).as_ref()[..16].to_vec(); + outer.insert(Value::Integer(3), Value::Integer(1)); + outer.insert(Value::Integer(4), Value::Bytes(mac)); + } + + let inner = to_vec(&Value::Map(outer)).map_err(|e| PFError::Io(e.to_string()))?; + let mut full_payload = vec![RSKEY_CTAPHID_VENDOR_CMD]; + full_payload.extend(inner); + + // Touch-gated variants block until the button is pressed — allow ~30 s. + const VENDOR_TOUCH_TIMEOUT_MS: i32 = 32_000; + let resp = self.send_raw_with_timeout(CTAPHID_CBOR, &full_payload, VENDOR_TOUCH_TIMEOUT_MS)?; + if resp.is_empty() { + return Err(PFError::Device("empty vendor response".into())); + } + let status = resp[0]; + let map = if status == 0 && resp.len() > 1 { + from_slice::(&resp[1..]).ok() + } else { + None + }; + Ok((status, map)) + } + + fn authconfig_vendor( + &self, + pin_token: &[u8], + vendor_id: u64, + param: Option, + ) -> Result<(), PFError> { + let mut sub = BTreeMap::new(); + sub.insert(Value::Integer(0x01), Value::Integer(vendor_id as i128)); + if let Some(p) = param { + let key = match &p { + Value::Bytes(_) => 0x02, + Value::Integer(_) => 0x03, + Value::Text(_) => 0x04, + _ => return Err(PFError::Io("unsupported vendor parameter type".into())), + }; + sub.insert(Value::Integer(key), p); + } + let sub_val = Value::Map(sub); + let sub_bytes = to_vec(&sub_val).map_err(|e| PFError::Io(e.to_string()))?; + + let pin_auth = + self.sign_config_command(pin_token, ConfigSubCommand::VendorPrototype as u8, &sub_bytes); + + let mut cfg = BTreeMap::new(); + cfg.insert( + Value::Integer(ConfigParam::SubCommand as i128), + Value::Integer(ConfigSubCommand::VendorPrototype as i128), + ); + cfg.insert(Value::Integer(ConfigParam::SubCommandParams as i128), sub_val); + cfg.insert(Value::Integer(ConfigParam::PinUvAuthProtocol as i128), Value::Integer(1)); + cfg.insert( + Value::Integer(ConfigParam::PinUvAuthParam as i128), + Value::Bytes(pin_auth), + ); + + let cbor = to_vec(&Value::Map(cfg)).map_err(|e| PFError::Io(e.to_string()))?; + let mut payload = vec![CtapCommand::Config as u8]; + payload.extend(cbor); + // A soft-lock toggle waits on a touch — allow ~30 s. + const AUTCFG_TIMEOUT_MS: i32 = 32_000; + self.send_cbor_with_timeout(CTAPHID_CBOR, &payload, AUTCFG_TIMEOUT_MS) + .map(|_| ()) + } + /// Sign a credential management command using HMAC-SHA-256. /// /// Uses pico-fido's non-standard signing scheme: for sub-commands 0x01 diff --git a/src/hal/firmwares/applets.rs b/src/hal/firmwares/applets.rs new file mode 100644 index 0000000..537ccc9 --- /dev/null +++ b/src/hal/firmwares/applets.rs @@ -0,0 +1,110 @@ +//! Per-firmware applet feature profiles. +//! +//! Extends the `firmwares/` capability seam to the CCID applets. A firmware +//! answers "does my applet support feature X?" through [`AppletProfile`], and +//! the applet screens read the answer to show or disable controls. Adding a new +//! firmware means one `impl AppletProfile` here — the shared wire ops in +//! [`crate::hal::applets`], the transport, and the UI stay untouched. + +use crate::hal::applets::{OathFeatures, OpenPgpFeatures, OtpFeatures, PivFeatures}; +use crate::hal::firmwares::{AnyFirmware, PicoFidoFirmware, RSKeyFirmware}; + +/// Feature answers per applet. `None` means the firmware exposes no such applet +/// (or it is not yet characterised) → the screen renders an honest empty state +/// rather than offering controls the wire can't back. +pub trait AppletProfile { + fn oath(&self) -> Option { + None + } + fn otp(&self) -> Option { + None + } + fn piv(&self) -> Option { + None + } + fn openpgp(&self) -> Option { + None + } +} + +impl AppletProfile for RSKeyFirmware { + fn oath(&self) -> Option { + // From the RS-Key rsk-oath wire audit: full Yubico-Authenticator parity. + Some(OathFeatures { + rename: true, + touch: true, + sha512: true, + password: true, + }) + } + + fn otp(&self) -> Option { + // rsk-otp implements all four slot types over four slots (3/4 = extension). + Some(OtpFeatures { + slots: 4, + chalresp: true, + hotp: true, + static_pw: true, + yubiotp: true, + swap: true, + }) + } + + fn piv(&self) -> Option { + Some(PivFeatures { + generate: true, + import_cert: true, + attestation: true, + retired_slots: true, + }) + } + + fn openpgp(&self) -> Option { + // rsk-openpgp implements OpenPGP Card 3.4 with RSA + the full EC set + // (Weierstrass / Ed25519 / X25519), per-key touch, and reset code. + Some(OpenPgpFeatures { + generate: true, + touch: true, + reset_code: true, + ecc: true, + }) + } +} + +// No applet data for pico-fido / other firmwares yet — a maintainer supplies it +// when the wire facts are known. Until then screens render "not characterised". +impl AppletProfile for PicoFidoFirmware {} + +impl AnyFirmware { + /// OATH feature profile for the inner firmware. + pub fn oath_features(&self) -> Option { + match self { + Self::PicoFido(fw) => fw.oath(), + Self::RSKey(fw) => fw.oath(), + } + } + + /// OTP feature profile for the inner firmware. + pub fn otp_features(&self) -> Option { + match self { + Self::PicoFido(fw) => fw.otp(), + Self::RSKey(fw) => fw.otp(), + } + } + + /// PIV feature profile for the inner firmware. + pub fn piv_features(&self) -> Option { + match self { + Self::PicoFido(fw) => fw.piv(), + Self::RSKey(fw) => fw.piv(), + } + } + + /// OpenPGP feature profile for the inner firmware. + pub fn openpgp_features(&self) -> Option { + match self { + Self::PicoFido(fw) => fw.openpgp(), + Self::RSKey(fw) => fw.openpgp(), + } + } +} diff --git a/src/hal/firmwares/mod.rs b/src/hal/firmwares/mod.rs index 229cbb9..37266cd 100644 --- a/src/hal/firmwares/mod.rs +++ b/src/hal/firmwares/mod.rs @@ -24,6 +24,7 @@ //! | `supports_rs_key_vendor_command` | Whether RS-Key-specific vendor commands (0x05 etc.) are available. | //! | `supports_rescue_channel` | Whether the PC/SC rescue channel is accessible. | +pub mod applets; pub mod picofido; pub mod rskey; diff --git a/src/hal/io.rs b/src/hal/io.rs index 04789a6..96fa83a 100644 --- a/src/hal/io.rs +++ b/src/hal/io.rs @@ -7,7 +7,10 @@ use crate::{ error::PFError, - hal::{fido, rescue, transport::DeviceHandle, types::*}, + hal::{ + applets::oath, applets::openpgp, applets::otp, applets::piv, fido, rescue, + transport::ccid::CcidSession, transport::DeviceHandle, types::*, + }, }; /// Read full device status by merging FIDO and Rescue data where available. @@ -58,6 +61,13 @@ pub fn read_device_details() -> Result { flash_used: rescue.info.flash_used, flash_total: rescue.info.flash_total, firmware_version: fido.info.firmware_version, + // The USB bcdDevice and iManufacturer come from the FIDO + // transport; the Rescue PC-SC channel has no USB descriptor. + bcd_device: fido.info.bcd_device, + manufacturer: fido.info.manufacturer, + // Object count / chip size come from the Rescue FlashInfo. + flash_files: rescue.info.flash_files, + flash_chip_size: rescue.info.flash_chip_size, }, config: AppConfig { vid: if !rescue.config.vid.is_empty() { @@ -90,11 +100,21 @@ pub fn read_device_details() -> Result { } else { fido.config.product_name }, + manufacturer_name: if !rescue.config.manufacturer_name.is_empty() { + rescue.config.manufacturer_name + } else { + fido.config.manufacturer_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, led_num: rescue.config.led_num, + // Effective values are only reported over FIDO (CONFIG_READ + // key 2); the rescue phy read has no equivalent. + effective_led_gpio: fido.config.effective_led_gpio, + effective_led_driver: fido.config.effective_led_driver, + effective_touch_timeout: fido.config.effective_touch_timeout, }, secure_boot: rescue.secure_boot, secure_lock: rescue.secure_lock, @@ -192,9 +212,12 @@ pub fn read_management_config(method: DeviceMethod) -> Result { let transport = crate::hal::transport::fido::HidTransport::open()?; let info = fido::read_rskey_management_info(&transport)?; + // Absent USB_ENABLED → all supported apps enabled (firmware default), + // not all-disabled, so a device without the field doesn't false-gate. + let usb_supported = info.usb_supported.unwrap_or(0); Ok(ManagementAppConfig { - usb_supported: info.usb_supported.unwrap_or(0), - usb_enabled: info.usb_enabled.unwrap_or(0), + usb_supported, + usb_enabled: info.usb_enabled.unwrap_or(usb_supported), }) } DeviceMethod::Rescue => rescue::read_management_config(), @@ -219,6 +242,36 @@ pub fn write_management_config( } } +/// Apply every changed configuration domain in one PIN/touch ceremony, so the +/// Configuration screen has a single Save. Order is load-bearing: the LED-status +/// and USB-applications writes run first (no reboot), then the phy record LAST — +/// an RS-Key phy write warm-reboots and re-enumerates the device. +pub fn write_all_config( + method: DeviceMethod, + phy: Option, + led: Option, + apps: Option, + pin: Option, +) -> Result { + let mut done = Vec::new(); + if let Some(led) = led { + write_led_config(method.clone(), led, pin.clone())?; + done.push("LED colours"); + } + if let Some(mask) = apps { + write_management_config(method.clone(), mask, pin.clone())?; + done.push("USB applications"); + } + if let Some(phy) = phy { + write_config(phy, method, pin)?; + done.push("device settings"); + } + if done.is_empty() { + return Ok("No changes to apply.".to_string()); + } + Ok(format!("Applied {}.", done.join(", "))) +} + /// Retrieve the FIDO authenticator metadata (GetInfo) as [`FidoDeviceInfo`]. pub(crate) fn get_fido_info() -> Result { fido::get_fido_info() @@ -272,3 +325,530 @@ pub fn upload_enterprise_attestation_cert( ) -> Result { fido::upload_enterprise_attestation_cert(pin, cert_path) } + +// ── Audit journal ───────────────────────────────────────────────────────────── + +/// Export the tamper-evident audit journal (PIN or touch gated). +pub fn audit_log(pin: Option) -> Result { + fido::audit_log(pin) +} + +/// Export + verify a DEVK-signed checkpoint; `expect_key` pins the identity. +pub fn audit_verify( + pin: Option, + expect_key: Option, +) -> Result { + fido::audit_verify(pin, expect_key) +} + +/// Whether the audit journal is on (ungated, no touch). +pub fn audit_status() -> Result { + fido::audit_status() +} + +/// Turn the audit journal on/off (PIN + touch); returns the resulting state. +pub fn audit_set_enabled(on: bool, pin: Option) -> Result { + fido::audit_set_enabled(on, pin) +} + +// ── Seed backup ─────────────────────────────────────────────────────────────── + +/// Read `{sealed, has_seed, locked, unlocked}`. +pub fn backup_status() -> Result { + fido::backup_status() +} + +/// Seal the one-time export window (touch). +pub fn backup_finalize() -> Result<(), String> { + fido::backup_finalize() +} + +/// Export the master seed as a 24-word BIP-39 phrase. +pub fn backup_export(pin: Option) -> Result { + fido::backup_export(pin) +} + +/// Restore a seed from a 24-word BIP-39 phrase. +pub fn backup_restore(pin: Option, mnemonic: String) -> Result<(), String> { + fido::backup_restore(pin, mnemonic) +} + +// ── At-rest soft lock ───────────────────────────────────────────────────────── + +/// Engage the lock; returns the lock key as a 24-word phrase. +pub fn lock_enable(pin: String) -> Result { + fido::lock_enable(pin) +} + +/// Unlock the seed for this power cycle (BIP-39 lock key). +pub fn lock_unlock(mnemonic: String) -> Result<(), String> { + fido::lock_unlock(mnemonic) +} + +/// Disable the lock (unlock + restore plaintext). +pub fn lock_disable(pin: String, mnemonic: String) -> Result<(), String> { + fido::lock_disable(pin, mnemonic) +} + +// ── Org attestation ─────────────────────────────────────────────────────────── + +/// Read `{installed, chain_hash}`. +pub fn att_status() -> Result { + fido::att_status() +} + +/// Remove the org attestation. +pub fn att_clear(pin: Option) -> Result<(), String> { + fido::att_clear(pin) +} + +/// Install an org attestation P-256 key + cert chain. +pub fn att_import( + pin: Option, + key_file: Vec, + chain_file: Vec, +) -> Result<(), String> { + fido::att_import(pin, key_file, chain_file) +} + +// ── Offboard (guided full wipe + signed receipt) ────────────────────────────── + +/// Wipe every applet, factory-reset FIDO, clear any org attestation, then sign +/// an audit checkpoint over the post-wipe journal. Each step is best-effort and +/// recorded; the receipt is signed only if the checkpoint verifies and contains +/// the RESET event. PIN-free by design (block-then-reset / touch-gated paths). +pub fn offboard(serial: String) -> Result { + use crate::hal::offboard::{OffboardReport, OffboardStep}; + + let mut steps: Vec = Vec::new(); + let step = |name: &str, res: Result<(), String>| OffboardStep { + name: name.to_string(), + ok: res.is_ok(), + detail: res.err().unwrap_or_else(|| "ok".to_string()), + }; + + // CCID applets first (each opens its own connection). + { + let mut ok = true; + let mut detail = String::from("ok"); + for slot in 1..=4u8 { + if let Err(e) = otp_delete(slot, [0u8; 6]) { + ok = false; + detail = format!("slot {slot}: {e}"); + break; + } + } + steps.push(OffboardStep { name: "otp".into(), ok, detail }); + } + steps.push(step("oath", oath_reset().map_err(|e| e.to_string()))); + steps.push(step("piv", piv_reset().map_err(|e| e.to_string()))); + steps.push(step("openpgp", openpgp_reset().map_err(|e| e.to_string()))); + + // FIDO factory reset (touch) — must precede the checkpoint so RESET is logged. + steps.push(step("fido_reset", reset_device().map(|_| ()))); + + // Org attestation — clear only if one is installed. + match att_status() { + Ok(s) if s.installed => steps.push(step("org_attestation", att_clear(None))), + Ok(_) => steps.push(OffboardStep { + name: "org_attestation".into(), + ok: true, + detail: "none".into(), + }), + Err(e) => steps.push(OffboardStep { + name: "org_attestation".into(), + ok: true, + detail: format!("status unavailable: {e}"), + }), + } + + // Signed receipt: checkpoint over the post-wipe journal (must hold RESET). + let (signed, fingerprint, signed_head, signature, pubkey) = match audit_verify(None, None) { + Ok(v) => { + let has_reset = v + .journal + .entries + .iter() + .any(|e| e.event == fido::audit::EVT_RESET); + if v.signature_ok && v.head_matches && has_reset { + ( + true, + Some(v.fingerprint), + Some(v.signed_head_hex), + Some(v.signature_hex), + Some(v.pubkey_hex), + ) + } else { + (false, None, None, None, None) + } + } + Err(_) => (false, None, None, None, None), + }; + + Ok(OffboardReport { + serial, + steps, + signed, + fingerprint, + signed_head, + signature, + pubkey, + }) +} + +// ── OATH (Accounts) ───────────────────────────────────────────────────────── +// +// Each operation is one open→(validate)→op unit: SELECT resets the applet's +// security state, so the VALIDATE and the operation it authorises must share +// the same freshly-opened session. + +/// Open the OATH applet and unlock it with `password` when a code is set. +fn oath_unlock(password: Option<&str>) -> Result { + let (session, info) = oath::open()?; + if info.password_set() { + let pw = password.ok_or_else(|| { + PFError::Device("This device's OATH accounts are password-protected.".into()) + })?; + let key = oath::derive_access_key(pw, &info.device_id); + let challenge = info.challenge.clone().unwrap_or_default(); + oath::validate(&session, &key, &challenge)?; + } + Ok(session) +} + +/// Whether the OATH applet has an access code set (needs a password to read). +pub fn oath_password_required() -> Result { + let (_, info) = oath::open()?; + Ok(info.password_set()) +} + +/// List every account with its current code (one CALCULATE ALL round-trip). +pub fn oath_list_accounts(password: Option) -> Result, PFError> { + oath::calculate_all(&oath_unlock(password.as_deref())?) +} + +/// Compute a single account's code (for HOTP or non-30 s / touch credentials). +pub fn oath_calculate( + password: Option, + id: String, + period: u32, +) -> Result { + oath::calculate(&oath_unlock(password.as_deref())?, &id, period) +} + +/// Add or overwrite a credential. +pub fn oath_add(password: Option, cred: oath::NewCredential) -> Result<(), PFError> { + oath::put(&oath_unlock(password.as_deref())?, &cred) +} + +/// Delete a credential by id. +pub fn oath_delete(password: Option, id: String) -> Result<(), PFError> { + oath::delete(&oath_unlock(password.as_deref())?, &id) +} + +/// Rename a credential (change its issuer/account; YubiKey 5.3+ / RS-Key). +pub fn oath_rename( + password: Option, + old_id: String, + new_id: String, +) -> Result<(), PFError> { + oath::rename(&oath_unlock(password.as_deref())?, &old_id, &new_id) +} + +/// Set, change, or clear the applet access code. `new_password = None` clears it. +pub fn oath_set_password( + current: Option, + new_password: Option, +) -> Result<(), PFError> { + let session = oath_unlock(current.as_deref())?; + match new_password { + Some(pw) if !pw.is_empty() => { + let info = oath::parse_select(&session.select_resp); + let key = oath::derive_access_key(&pw, &info.device_id); + oath::set_code(&session, &key) + } + _ => oath::clear_code(&session), + } +} + +/// Factory-reset the OATH applet (wipes all accounts and the access code). +pub fn oath_reset() -> Result<(), PFError> { + let (session, _) = oath::open()?; + oath::reset(&session) +} + +// ── OTP (Slots) ───────────────────────────────────────────────────────────── + +/// Read per-slot status (all four slots). +pub fn otp_read_info() -> Result<[otp::SlotInfo; 4], PFError> { + otp::read_info(&otp::open()?) +} + +/// Program a slot for HMAC-SHA1 challenge-response. +pub fn otp_program_chalresp( + slot: u8, + secret: Vec, + touch: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], +) -> Result<(), PFError> { + otp::configure( + &otp::open()?, + slot, + &otp::build_chalresp(&secret, touch, &new_acc), + ¤t_acc, + ) +} + +/// Program a slot for OATH-HOTP. +pub fn otp_program_hotp( + slot: u8, + secret: Vec, + digits8: bool, + append_cr: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], +) -> Result<(), PFError> { + otp::configure( + &otp::open()?, + slot, + &otp::build_hotp(&secret, digits8, append_cr, &new_acc), + ¤t_acc, + ) +} + +/// Program a slot for a static password (ASCII typed as HID scancodes). +pub fn otp_program_static( + slot: u8, + scancodes: Vec, + append_cr: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], +) -> Result<(), PFError> { + otp::configure( + &otp::open()?, + slot, + &otp::build_static(&scancodes, append_cr, &new_acc), + ¤t_acc, + ) +} + +/// Program a slot for Yubico OTP (public id ‖ private id ‖ AES key). +#[allow(clippy::too_many_arguments)] +pub fn otp_program_yubico( + slot: u8, + public_id: Vec, + private_id: [u8; 6], + key: [u8; 16], + append_cr: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], +) -> Result<(), PFError> { + otp::configure( + &otp::open()?, + slot, + &otp::build_yubico_otp(&public_id, &private_id, &key, append_cr, &new_acc), + ¤t_acc, + ) +} + +/// Delete a slot, presenting `current_acc` (all-zero for an unprotected slot). +pub fn otp_delete(slot: u8, current_acc: [u8; 6]) -> Result<(), PFError> { + otp::delete_slot(&otp::open()?, slot, ¤t_acc) +} + +/// Swap slots 1 and 2, presenting `current_acc` (all-zero for unprotected slots). +pub fn otp_swap(current_acc: [u8; 6]) -> Result<(), PFError> { + otp::swap(&otp::open()?, ¤t_acc) +} + +/// Run an HMAC-SHA1 challenge-response against a slot (returns the 20-byte MAC). +pub fn otp_calculate(slot: u8, challenge: Vec) -> Result, PFError> { + otp::calculate_hmac(&otp::open()?, slot, &challenge) +} + +// ── PIV ───────────────────────────────────────────────────────────────────── +// +// Management-gated ops (generate, import cert, set mgmt key, set retries) run +// GENERAL AUTHENTICATE and the op on the SAME session — SELECT resets auth. + +/// How a management-gated op authenticates: a typed management key, or a PIN +/// that fetches the PIN-protected key on the same session (ykman `--protect`). +pub enum MgmAuth { + Key { key: Vec, algo: u8 }, + Pin(String), +} + +/// Open the PIV applet and authenticate the management key on that session. +fn piv_authed(auth: MgmAuth) -> Result { + let s = piv::open()?; + match auth { + MgmAuth::Key { key, algo } => piv::authenticate_mgm(&s, &key, algo)?, + MgmAuth::Pin(pin) => { + // --protect: fetch the random key by PIN, then authenticate — one + // session, so the SELECT that would reset auth never intervenes. + let key = piv::read_protected_mgm(&s, &pin)?; + piv::authenticate_mgm(&s, &key, piv::mgm_algo_for_len(key.len()))?; + } + } + Ok(s) +} + +pub fn piv_read_info() -> Result { + piv::read_info(&piv::open()?) +} + +pub fn piv_change_pin(old: String, new: String) -> Result<(), PFError> { + piv::change_ref(&piv::open()?, piv::REF_PIN, &old, &new) +} + +pub fn piv_change_puk(old: String, new: String) -> Result<(), PFError> { + piv::change_ref(&piv::open()?, piv::REF_PUK, &old, &new) +} + +pub fn piv_unblock_pin(puk: String, new_pin: String) -> Result<(), PFError> { + piv::unblock_pin(&piv::open()?, &puk, &new_pin) +} + +pub fn piv_generate( + slot: u8, + algo: u8, + pin_policy: u8, + touch_policy: u8, + auth: MgmAuth, +) -> Result, PFError> { + let s = piv_authed(auth)?; + piv::generate(&s, slot, algo, pin_policy, touch_policy) +} + +pub fn piv_export_cert(slot: u8) -> Result, PFError> { + piv::export_cert(&piv::open()?, slot) +} + +pub fn piv_import_cert(slot: u8, der: Vec, auth: MgmAuth) -> Result<(), PFError> { + let s = piv_authed(auth)?; + piv::import_cert(&s, slot, &der) +} + +pub fn piv_delete_cert(slot: u8, auth: MgmAuth) -> Result<(), PFError> { + let s = piv_authed(auth)?; + piv::delete_cert(&s, slot) +} + +pub fn piv_set_mgm( + current: MgmAuth, + new_algo: u8, + new_key: Vec, + touch: bool, +) -> Result<(), PFError> { + let s = piv_authed(current)?; + piv::set_mgm(&s, new_algo, &new_key, touch) +} + +pub fn piv_set_retries( + auth: MgmAuth, + pin: String, + pin_tries: u8, + puk_tries: u8, +) -> Result<(), PFError> { + let s = piv_authed(auth)?; + piv::verify_pin(&s, &pin)?; + piv::set_retries(&s, pin_tries, puk_tries) +} + +pub fn piv_reset() -> Result<(), PFError> { + piv::reset(&piv::open()?) +} + +/// Attestation certificate DER for a generated key. +pub fn piv_attest(slot: u8) -> Result, PFError> { + piv::attest(&piv::open()?, slot) +} + +pub fn piv_move_key(src: u8, dst: u8, auth: MgmAuth) -> Result<(), PFError> { + let s = piv_authed(auth)?; + piv::move_key(&s, src, dst) +} + +pub fn piv_delete_key(slot: u8, auth: MgmAuth) -> Result<(), PFError> { + let s = piv_authed(auth)?; + piv::delete_key(&s, slot) +} + +/// Import a private key from PEM/DER (PKCS8 / PKCS1 / SEC1). +pub fn piv_import_key(slot: u8, key_file: Vec, auth: MgmAuth) -> Result<(), PFError> { + let (algo, material) = piv::parse_private_key(&key_file).map_err(PFError::Device)?; + let s = piv_authed(auth)?; + piv::import_key(&s, slot, algo, &material) +} + +// ── OpenPGP ─────────────────────────────────────────────────────────────────── +// +// Admin-gated writes (cardholder, touch, reset code, generate) run VERIFY PW3 +// and the op on the SAME session — SELECT clears the verification latch. + +/// Open the OpenPGP applet and verify the admin PIN (PW3) on that session. +fn openpgp_admin(admin: &str) -> Result { + let s = openpgp::open()?; + openpgp::verify_pin(&s, openpgp::PW3, admin)?; + Ok(s) +} + +pub fn openpgp_read_info() -> Result { + openpgp::read_info(&openpgp::open()?) +} + +pub fn openpgp_change_user_pin(old: String, new: String) -> Result<(), PFError> { + openpgp::change_pin(&openpgp::open()?, openpgp::PW1, &old, &new) +} + +pub fn openpgp_change_admin_pin(old: String, new: String) -> Result<(), PFError> { + openpgp::change_pin(&openpgp::open()?, openpgp::PW3, &old, &new) +} + +/// Unblock the user PIN with the resetting code. +pub fn openpgp_unblock_with_code(rc: String, new_pin: String) -> Result<(), PFError> { + openpgp::unblock_with_rc(&openpgp::open()?, &rc, &new_pin) +} + +/// Unblock the user PIN with the admin PIN. +pub fn openpgp_unblock_with_admin(admin: String, new_pin: String) -> Result<(), PFError> { + openpgp::unblock_with_admin(&openpgp_admin(&admin)?, &new_pin) +} + +/// Set (or clear, if `new_rc` is empty) the resetting code. +pub fn openpgp_set_reset_code(admin: String, new_rc: String) -> Result<(), PFError> { + openpgp::set_reset_code(&openpgp_admin(&admin)?, &new_rc) +} + +pub fn openpgp_set_cardholder( + admin: String, + name: String, + login: String, + url: String, + lang: String, + sex: u8, +) -> Result<(), PFError> { + openpgp::set_cardholder(&openpgp_admin(&admin)?, &name, &login, &url, &lang, sex) +} + +pub fn openpgp_set_touch(admin: String, slot: openpgp::PgpSlot, on: bool) -> Result<(), PFError> { + openpgp::set_touch(&openpgp_admin(&admin)?, slot, on) +} + +/// Generate a key in a slot with the given algorithm choice (see `GENERATE_ALGOS`). +pub fn openpgp_generate( + admin: String, + slot: openpgp::PgpSlot, + choice: u8, +) -> Result<(), PFError> { + let attr = openpgp::algo_attr(slot, choice).ok_or_else(|| { + PFError::Device(format!("Algorithm not supported for the {} slot", slot.label())) + })?; + let s = openpgp_admin(&admin)?; + openpgp::generate(&s, slot, &attr).map(|_| ()) +} + +pub fn openpgp_reset() -> Result<(), PFError> { + openpgp::reset(&openpgp::open()?) +} diff --git a/src/hal/mod.rs b/src/hal/mod.rs index 96dc73a..520569b 100644 --- a/src/hal/mod.rs +++ b/src/hal/mod.rs @@ -33,10 +33,13 @@ //! [`io`] sits on top and exposes one function per device operation, //! selecting the correct protocol path based on the detected firmware. +pub mod apdu; +pub mod applets; pub mod common; pub mod fido; pub mod firmwares; pub mod io; +pub mod offboard; pub mod rescue; pub mod transport; pub mod types; diff --git a/src/hal/offboard.rs b/src/hal/offboard.rs new file mode 100644 index 0000000..96bfe26 --- /dev/null +++ b/src/hal/offboard.rs @@ -0,0 +1,127 @@ +//! Offboard receipt — the report of a full-device wipe plus its signed +//! checkpoint. Pure data + JSON rendering, so it is host-tested; the wipe +//! orchestration lives in [`crate::hal::io`]. + +/// One wipe step's outcome. +#[derive(Debug, Clone)] +pub struct OffboardStep { + pub name: String, + pub ok: bool, + pub detail: String, +} + +/// The full offboard receipt. +#[derive(Debug, Clone)] +pub struct OffboardReport { + pub serial: String, + pub steps: Vec, + pub signed: bool, + pub fingerprint: Option, + pub signed_head: Option, + pub signature: Option, + pub pubkey: Option, +} + +fn json_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +impl OffboardReport { + /// Whether every wipe step succeeded. + pub fn all_ok(&self) -> bool { + self.steps.iter().all(|s| s.ok) + } + + /// The names of the failed steps. + pub fn failures(&self) -> Vec<&str> { + self.steps.iter().filter(|s| !s.ok).map(|s| s.name.as_str()).collect() + } + + /// Render the receipt as pretty JSON. `timestamp` is supplied by the caller + /// (this module has no clock). + pub fn to_json(&self, timestamp: &str) -> String { + let mut steps = String::from("{"); + for (i, s) in self.steps.iter().enumerate() { + if i > 0 { + steps.push(','); + } + steps.push_str(&format!("\n {}: {}", json_str(&s.name), json_str(&s.detail))); + } + steps.push_str("\n }"); + + let mut out = String::from("{\n"); + out.push_str(&format!(" \"device\": {},\n", json_str(&self.serial))); + out.push_str(&format!(" \"timestamp\": {},\n", json_str(timestamp))); + out.push_str(&format!(" \"steps\": {},\n", steps)); + out.push_str(&format!(" \"signed\": {}", self.signed)); + if self.signed { + let f = |o: &Option| o.clone().unwrap_or_default(); + out.push_str(&format!(",\n \"fingerprint\": {}", json_str(&f(&self.fingerprint)))); + out.push_str(&format!(",\n \"signed_head\": {}", json_str(&f(&self.signed_head)))); + out.push_str(&format!(",\n \"signature\": {}", json_str(&f(&self.signature)))); + out.push_str(&format!(",\n \"attestation_pubkey\": {}", json_str(&f(&self.pubkey)))); + } + out.push_str("\n}\n"); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn step(name: &str, ok: bool) -> OffboardStep { + OffboardStep { name: name.into(), ok, detail: if ok { "ok".into() } else { "boom".into() } } + } + + #[test] + fn all_ok_and_failures() { + let r = OffboardReport { + serial: "123".into(), + steps: vec![step("otp", true), step("piv", false)], + signed: false, + fingerprint: None, + signed_head: None, + signature: None, + pubkey: None, + }; + assert!(!r.all_ok()); + assert_eq!(r.failures(), vec!["piv"]); + } + + #[test] + fn json_has_fields_and_escapes() { + let r = OffboardReport { + serial: "37302053".into(), + steps: vec![step("otp", true)], + signed: true, + fingerprint: Some("abcd".into()), + signed_head: Some("dead".into()), + signature: Some("beef".into()), + pubkey: Some("04aa".into()), + }; + let j = r.to_json("2026-07-21T00:00:00"); + assert!(j.contains("\"device\": \"37302053\"")); + assert!(j.contains("\"signed\": true")); + assert!(j.contains("\"fingerprint\": \"abcd\"")); + assert!(j.contains("\"attestation_pubkey\": \"04aa\"")); + // A quote in a detail string must be escaped. + let bad = OffboardStep { name: "x".into(), ok: false, detail: "a\"b".into() }; + let r2 = OffboardReport { steps: vec![bad], ..r }; + assert!(r2.to_json("t").contains("a\\\"b")); + } +} diff --git a/src/hal/rescue/constants.rs b/src/hal/rescue/constants.rs index 64d0bba..b342adf 100644 --- a/src/hal/rescue/constants.rs +++ b/src/hal/rescue/constants.rs @@ -319,6 +319,8 @@ pub enum PhyTag { /// RS-Key specific tag specifying how many individual LEDs /// are present (e.g., 1 for single, 3 for RGB). LedNum = 0x0E, + /// USB iManufacturer string (null-terminated), an RS-Key extension. + UsbManufacturer = 0x0F, } impl PhyTag { @@ -339,6 +341,7 @@ impl PhyTag { 0x0C => Some(Self::LedDriver), 0x0D => Some(Self::LedOrder), 0x0E => Some(Self::LedNum), + 0x0F => Some(Self::UsbManufacturer), _ => None, } } diff --git a/src/hal/rescue/ops.rs b/src/hal/rescue/ops.rs index 81f25f1..ca86f0f 100644 --- a/src/hal/rescue/ops.rs +++ b/src/hal/rescue/ops.rs @@ -226,15 +226,19 @@ impl RescueOperations for PcscTransport { let version_major = select_resp[2]; let version_minor = select_resp[3]; - // FIX: Handle missing Serial Number safely // If the firmware sends 14 bytes, we have a serial. If it sends 6, we don't. + // The 8-byte chip id sits at [4..12]; the user-facing serial is the 8-digit + // Yubico decimal (first 4 bytes, top 6 bits of byte 0 cleared, big-endian) — + // the same value the device reports over PIV/OTP/OpenPGP GET SERIAL and that + // ykman/YubiKey Manager display. See rsk_mgmt::serial4. let serial_str = if select_resp.len() >= 14 { - hex::encode_upper(&select_resp[4..12]) + let id = &select_resp[4..12]; + u32::from_be_bytes([id[0] & 0x03, id[1], id[2], id[3]]).to_string() } else { log::warn!( "Device did not return a Serial Number (Firmware mismatch?). Using placeholder." ); - "00000000".to_string() + "0".to_string() }; log::info!("Device Version: {}.{}", version_major, version_minor); @@ -257,14 +261,13 @@ impl RescueOperations for PcscTransport { return Err(PFError::Device("Failed to read flash".into())); } + // FlashInfo layout: free, used, total(=KV partition), nfiles, chip_size. let mut cursor = Cursor::new(&flash_response[..flash_response.len() - 2]); let _free = cursor.read_u32::().unwrap_or(0); let used = cursor.read_u32::().unwrap_or(0); let total = cursor.read_u32::().unwrap_or(0); - - // NOTE: captured but currently unused variables - let _nfiles = cursor.read_u32::().unwrap_or(0); - let _chip_size = cursor.read_u32::().unwrap_or(0); + let nfiles = cursor.read_u32::().unwrap_or(0); + let chip_size = cursor.read_u32::().unwrap_or(0); // --- Read Secure Boot Status --- let secure_response = self.transmit( @@ -346,6 +349,12 @@ impl RescueOperations for PcscTransport { .trim_matches(char::from(0)); config.product_name = product_str.to_string(); } + PhyTag::UsbManufacturer => { + let mfr = std::str::from_utf8(field_data) + .unwrap_or("") + .trim_matches(char::from(0)); + config.manufacturer_name = mfr.to_string(); + } PhyTag::Opts => { if field_data.len() >= 2 { let options_raw = u16::from_be_bytes([field_data[0], field_data[1]]); @@ -408,6 +417,12 @@ impl RescueOperations for PcscTransport { flash_used: Some(used / 1024), flash_total: Some(total / 1024), firmware_version: format!("{}.{}", version_major, version_minor), + // No USB descriptor over the Rescue/PC-SC channel. + bcd_device: None, + manufacturer: None, + flash_files: Some(nfiles), + // 0 = an older firmware that doesn't report the chip size. + flash_chip_size: (chip_size > 0).then_some(chip_size), }, config, secure_boot: sb_enabled, @@ -538,6 +553,19 @@ impl RescueOperations for PcscTransport { tlv.push(0x00); } + // Manufacturer Name (Tag 0x0F) + if let Some(mfr) = config.manufacturer_name.filter(|n| !n.is_empty()) { + let mfr_bytes = mfr.as_bytes(); + let len = mfr_bytes.len() + 1; + if len > 32 { + return Err(PFError::Io("Manufacturer name too long".into())); + } + tlv.push(PhyTag::UsbManufacturer as u8); + tlv.push(len as u8); + tlv.extend_from_slice(mfr_bytes); + tlv.push(0x00); + } + // LED Order (Tag 0x0D) — RS-Key extension, silently preserved if let Some(val) = config.led_order { tlv.push(PhyTag::LedOrder as u8); @@ -553,8 +581,9 @@ 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. + // LED count (Tag 0x0E) — RS-Key extension. The rescue WRITE 0x1C merges + // (RS-Key bcd 0x083A+), so an omitted tag is preserved; emit it anyway to + // faithfully round-trip the value the device reported. if let Some(val) = config.led_num { tlv.push(PhyTag::LedNum as u8); tlv.push(0x01); @@ -593,7 +622,7 @@ impl RescueOperations for PcscTransport { /// Reboots the device, optionally entering BOOTSEL (mass storage) mode for firmware updates. /// - /// Sends a REBOOT APDU: `80 1B [P1] 00 00` where: + /// Sends a REBOOT APDU: `80 1F [P1] 00 00` where: /// - `P1 = 0x00` (`RebootParam::Normal`): Reboots into normal FIDO mode /// - `P1 = 0x01` (`RebootParam::Bootsel`): Reboots into BOOTSEL/UF2 bootloader mode /// @@ -788,6 +817,7 @@ impl RescueOperations for PcscTransport { }; let mut config = ManagementAppConfig::default(); + let mut enabled_seen = false; let mut offset = 0; while offset < tlv_data.len() { if offset + 2 > tlv_data.len() { @@ -809,6 +839,7 @@ impl RescueOperations for PcscTransport { MGMT_TAG_USB_ENABLED => { if field_data.len() >= 2 { config.usb_enabled = u16::from_be_bytes([field_data[0], field_data[1]]); + enabled_seen = true; } } _ => { @@ -817,6 +848,11 @@ impl RescueOperations for PcscTransport { } offset += field_len; } + // An absent USB_ENABLED tag means "all supported apps enabled" (firmware + // enabled_from_conf), not "all disabled" — else every applet false-gates. + if !enabled_seen { + config.usb_enabled = config.usb_supported; + } log::info!( "Management config: supported=0x{:04X}, enabled=0x{:04X}", diff --git a/src/hal/transport/ccid.rs b/src/hal/transport/ccid.rs new file mode 100644 index 0000000..c4b7456 --- /dev/null +++ b/src/hal/transport/ccid.rs @@ -0,0 +1,139 @@ +//! Persistent CCID (PC/SC) session for the smart-card applets. +//! +//! Unlike [`super::pcsc::PcscTransport`] (one fresh connection per rescue op), +//! a `CcidSession` **holds the card open** across calls. That is mandatory for +//! the applets: `SELECT` resets the card's security status, so a VERIFY and the +//! operation it authorises must run on the same open card. It also grows the rx +//! buffer past 256 bytes and implements `61xx`/`6Cxx` response assembly and +//! `CLA|0x10` command-chaining, none of which the rescue transport has. + +// `send_chained` is consumed by the PIV/OpenPGP import stages, not by OATH. +#![allow(dead_code)] + +use crate::error::PFError; +use crate::hal::apdu::{ + Apdu, StatusWord, CLA_CHAIN, CLA_ISO, INS_GET_RESPONSE, INS_SELECT, INS_SEND_REMAINING, +}; +use pcsc::{Context, Protocols, Scope, ShareMode}; + +/// Largest single PC/SC response chunk we read before assembling via GET RESPONSE. +const RX_BUF: usize = 4096; +/// Largest command-data fragment in a chained write (ISO short Lc max). +const CHAIN_CHUNK: usize = 255; + +/// An open PC/SC card bound to one applet (selected by AID). +pub struct CcidSession { + card: pcsc::Card, + /// The applet's `SELECT` response (FCI / version block), parsed per applet. + pub select_resp: Vec, +} + +impl CcidSession { + /// Connect to the first reader and `SELECT` the given applet AID. + /// + /// The card is kept open for the session's lifetime so a subsequent VERIFY + /// stays in effect for the following operation. + pub fn open(aid: &[u8]) -> Result { + let ctx = Context::establish(Scope::User).map_err(PFError::Pcsc)?; + let mut readers_buf = [0; 2048]; + let reader = ctx + .list_readers(&mut readers_buf)? + .next() + .ok_or(PFError::NoDevice)?; + let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?; + + let mut session = Self { card, select_resp: Vec::new() }; + let select = Apdu::read(CLA_ISO, INS_SELECT, 0x04, 0x00, aid); + session.select_resp = session.transceive_full(&select).map_err(|e| { + PFError::Device(format!( + "Applet not available — enable it and the CCID interface in Configuration ({e})" + )) + })?; + Ok(session) + } + + /// Send one APDU, returning `(response_data, status_word)`. + pub fn transceive(&self, apdu: &Apdu) -> Result<(Vec, StatusWord), PFError> { + let tx = apdu.encode(); + let mut rx = [0u8; RX_BUF]; + let resp = self.card.transmit(&tx, &mut rx).map_err(PFError::Pcsc)?; + if resp.len() < 2 { + return Err(PFError::Device("Truncated APDU response".into())); + } + let (data, sw) = resp.split_at(resp.len() - 2); + Ok((data.to_vec(), StatusWord(u16::from_be_bytes([sw[0], sw[1]])))) + } + + /// Send an APDU and assemble the full response across `61xx` continuations, + /// retrying once on `6Cxx`. Errors on any non-`9000` final SW. Continues a + /// `61xx` page with ISO GET RESPONSE (`0xC0`) — the right form for every + /// applet except OATH (see [`transceive_oath`](Self::transceive_oath)). + pub fn transceive_full(&self, apdu: &Apdu) -> Result, PFError> { + self.transceive_paged(apdu, INS_GET_RESPONSE) + } + + /// Like [`transceive_full`](Self::transceive_full) but continues a `61xx` + /// page with YKOATH SEND REMAINING (`0xA5`) instead of GET RESPONSE. The + /// OATH applet paginates LIST / CALCULATE ALL this way and rejects `0xC0` + /// with `6D00`, discarding the pending page — so its two paged reads must + /// use this. (Matches a real YubiKey, which pages OATH identically.) + pub fn transceive_oath(&self, apdu: &Apdu) -> Result, PFError> { + self.transceive_paged(apdu, INS_SEND_REMAINING) + } + + fn transceive_paged(&self, apdu: &Apdu, continue_ins: u8) -> Result, PFError> { + let mut cmd = apdu.clone(); + let mut out = Vec::new(); + loop { + let (data, sw) = self.transceive(&cmd)?; + if let Some(n) = sw.wrong_le() { + // 6Cxx: resend the same command with the corrected Le, no data yet. + cmd.le = Some(if n == 0 { 256 } else { n as u16 }); + continue; + } + out.extend_from_slice(&data); + if let Some(n) = sw.more_data() { + cmd = Apdu::read(CLA_ISO, continue_ins, 0, 0, &[]); + cmd.le = Some(if n == 0 { 256 } else { n as u16 }); + continue; + } + if sw.is_ok() { + return Ok(out); + } + return Err(sw.to_error()); + } + } + + /// Send a command whose data exceeds 255 bytes via ISO command-chaining + /// (`CLA|0x10` on every fragment but the last). Used for PIV/OpenPGP import. + pub fn send_chained(&self, apdu: &Apdu) -> Result, PFError> { + if apdu.data.len() <= CHAIN_CHUNK { + return self.transceive_full(apdu); + } + let data = apdu.data.clone(); + let mut i = 0; + while data.len() - i > CHAIN_CHUNK { + let frag = Apdu::write( + apdu.cla | CLA_CHAIN, + apdu.ins, + apdu.p1, + apdu.p2, + &data[i..i + CHAIN_CHUNK], + ); + let (_, sw) = self.transceive(&frag)?; + if !sw.is_ok() { + return Err(sw.to_error()); + } + i += CHAIN_CHUNK; + } + let last = Apdu { + cla: apdu.cla, + ins: apdu.ins, + p1: apdu.p1, + p2: apdu.p2, + data: data[i..].to_vec(), + le: apdu.le, + }; + self.transceive_full(&last) + } +} diff --git a/src/hal/transport/fido.rs b/src/hal/transport/fido.rs index 1b00b5a..50a059a 100644 --- a/src/hal/transport/fido.rs +++ b/src/hal/transport/fido.rs @@ -159,6 +159,12 @@ pub struct HidTransport { pub vid: u16, pub pid: u16, pub product_name: String, + /// USB iManufacturer string. RS-Key derives it from the effective VID at + /// runtime (Yubico when the VID is 0x1050), so it is display-only, not settable. + pub manufacturer: Option, + /// USB bcdDevice from the device descriptor. RS-Key encodes its real build + /// id here (the CTAP getInfo firmwareVersion is an impersonated YubiKey value). + pub release_number: u16, } impl HidTransport { @@ -191,6 +197,8 @@ impl HidTransport { let vid = info.vendor_id(); let pid = info.product_id(); + let release_number = info.release_number(); + let manufacturer = info.manufacturer_string().map(|s| s.to_string()); let product_name = info .product_string() .unwrap_or("Unknown FIDO Device") @@ -214,6 +222,8 @@ impl HidTransport { vid, pid, product_name, + manufacturer, + release_number, }) } @@ -340,6 +350,18 @@ impl HidTransport { self.read_hid_response(cmd, HID_TOTAL_TIMEOUT_MS) } + /// Like [`send_raw`](HidTransport::send_raw) but with a caller-chosen read + /// timeout — for touch-gated vendor commands that block on a button press. + pub fn send_raw_with_timeout( + &self, + cmd: u8, + payload: &[u8], + timeout_ms: i32, + ) -> Result, PFError> { + self.write_cbor_request(cmd, payload)?; + self.read_hid_response(cmd, timeout_ms) + } + /// Send the CTAP authenticatorReset command (0x07). /// /// Resets the authenticator to its factory state: all credentials, PINs, diff --git a/src/hal/transport/mod.rs b/src/hal/transport/mod.rs index caabd54..c47eb20 100644 --- a/src/hal/transport/mod.rs +++ b/src/hal/transport/mod.rs @@ -25,6 +25,8 @@ use fido::HidTransport; pub mod pcsc; use pcsc::PcscTransport; +pub mod ccid; + /// A connected device handle over either the FIDO or rescue transport. pub enum DeviceHandle { /// Connected via CTAPHID (USB HID). diff --git a/src/hal/types.rs b/src/hal/types.rs index 3d5409c..b868408 100644 --- a/src/hal/types.rs +++ b/src/hal/types.rs @@ -25,6 +25,22 @@ pub struct DeviceInfo { pub flash_used: Option, pub flash_total: Option, pub firmware_version: String, + /// USB bcdDevice (FIDO transport only; `None` over the Rescue/PC-SC channel, + /// which has no USB descriptor). RS-Key's real build id, distinct from the + /// impersonated CTAP `firmware_version`. + #[serde(default)] + pub bcd_device: Option, + /// USB iManufacturer string (FIDO transport only). Display-only; RS-Key + /// derives it from the effective VID at runtime. + #[serde(default)] + pub manufacturer: Option, + /// Number of objects stored in the KV filesystem (RS-Key rescue FlashInfo). + #[serde(default)] + pub flash_files: Option, + /// Total onboard flash chip size in bytes (RS-Key rescue FlashInfo). Distinct + /// from `flash_total`, which is only the KV partition the credentials live in. + #[serde(default)] + pub flash_chip_size: Option, } /// Full device configuration (USB descriptors, LED, touch, crypto options). @@ -34,6 +50,10 @@ pub struct AppConfig { pub vid: String, pub pid: String, pub product_name: String, + /// USB iManufacturer override (phy tag 0x0F). Empty = the VID-derived + /// default (Yubico VID → "Yubico", else the build's manufacturer const). + #[serde(default)] + pub manufacturer_name: String, /// 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")] @@ -65,6 +85,16 @@ pub struct AppConfig { /// Number of individual LEDs on the device. #[serde(skip_serializing_if = "Option::is_none")] pub led_num: Option, + /// Boot-resolved effective values the device reports (CONFIG_READ key 2, RS-Key + /// 0x0852+): shown as placeholders where there is no explicit override, so the + /// UI displays the real value instead of a bare "firmware default". `None` = + /// not reported (older firmware, rescue transport, or a headless build). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_led_gpio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_led_driver: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_touch_timeout: Option, } /// Partial config update; `None` fields are left unchanged on the device. @@ -74,6 +104,7 @@ pub struct AppConfigInput { pub vid: Option, pub pid: Option, pub product_name: Option, + pub manufacturer_name: Option, pub led_gpio: Option, pub led_brightness: Option, pub touch_timeout: Option, From d321a57b6b06d141998b748ecbc8ef4460a1ae28 Mon Sep 17 00:00:00 2001 From: Maxim Muravev Date: Sat, 25 Jul 2026 13:54:59 +0300 Subject: [PATCH 3/7] feat(ui): RS-Key applet + management screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GUI for everything the new HAL exposes, plus a reorganised configuration surface: - Applet screens: Accounts (OATH, live TOTP), Slots (OTP), PIV and OpenPGP — ykman / Yubico-Authenticator-parity management, each gated by an `AppletGate` (CCID off / applet disabled / not supported). - Management screens: Audit (journal, checkpoint verify, on/off toggle), Backup, Lock, Attestation and Offboard. - Sidebar grouped into Device / Credentials / Protection / System sections, with Offboard moved down to just above About. - Configuration: an editable Manufacturer field; the effective LED pin / driver and touch timeout shown as placeholders instead of a bare "firmware default"; a single Apply that writes every changed domain in one ceremony; Hardware Endpoints trimmed to the interfaces the firmware actually builds (CCID/HID/KB); the LED-driver list without ESP32 on RS-Key. - Home surfaces the real firmware version, manufacturer, storage and the effective LED / timeout values. --- src/ui/app.rs | 154 ++- src/ui/components/applet_gate.rs | 44 + src/ui/components/form.rs | 56 ++ src/ui/components/mod.rs | 2 + src/ui/components/sidebar.rs | 71 +- src/ui/models/device.rs | 446 ++++++++- src/ui/screens/accounts/mod.rs | 3 + src/ui/screens/accounts/view.rs | 292 ++++++ src/ui/screens/accounts/view_model.rs | 763 +++++++++++++++ src/ui/screens/attestation/mod.rs | 3 + src/ui/screens/attestation/view.rs | 143 +++ src/ui/screens/attestation/view_model.rs | 268 ++++++ src/ui/screens/audit/mod.rs | 3 + src/ui/screens/audit/view.rs | 265 ++++++ src/ui/screens/audit/view_model.rs | 355 +++++++ src/ui/screens/backup/mod.rs | 3 + src/ui/screens/backup/view.rs | 213 +++++ src/ui/screens/backup/view_model.rs | 310 ++++++ src/ui/screens/config/view.rs | 332 +++---- src/ui/screens/config/view_model.rs | 571 ++++++----- src/ui/screens/home/view.rs | 94 +- src/ui/screens/lock/mod.rs | 3 + src/ui/screens/lock/view.rs | 218 +++++ src/ui/screens/lock/view_model.rs | 365 ++++++++ src/ui/screens/mod.rs | 9 + src/ui/screens/offboard/mod.rs | 3 + src/ui/screens/offboard/view.rs | 151 +++ src/ui/screens/offboard/view_model.rs | 215 +++++ src/ui/screens/openpgp/mod.rs | 3 + src/ui/screens/openpgp/view.rs | 298 ++++++ src/ui/screens/openpgp/view_model.rs | 573 +++++++++++ src/ui/screens/piv/mod.rs | 3 + src/ui/screens/piv/view.rs | 300 ++++++ src/ui/screens/piv/view_model.rs | 1094 ++++++++++++++++++++++ src/ui/screens/slots/mod.rs | 4 + src/ui/screens/slots/program_form.rs | 428 +++++++++ src/ui/screens/slots/view.rs | 147 +++ src/ui/screens/slots/view_model.rs | 450 +++++++++ 38 files changed, 8184 insertions(+), 471 deletions(-) create mode 100644 src/ui/components/applet_gate.rs create mode 100644 src/ui/components/form.rs create mode 100644 src/ui/screens/accounts/mod.rs create mode 100644 src/ui/screens/accounts/view.rs create mode 100644 src/ui/screens/accounts/view_model.rs create mode 100644 src/ui/screens/attestation/mod.rs create mode 100644 src/ui/screens/attestation/view.rs create mode 100644 src/ui/screens/attestation/view_model.rs create mode 100644 src/ui/screens/audit/mod.rs create mode 100644 src/ui/screens/audit/view.rs create mode 100644 src/ui/screens/audit/view_model.rs create mode 100644 src/ui/screens/backup/mod.rs create mode 100644 src/ui/screens/backup/view.rs create mode 100644 src/ui/screens/backup/view_model.rs create mode 100644 src/ui/screens/lock/mod.rs create mode 100644 src/ui/screens/lock/view.rs create mode 100644 src/ui/screens/lock/view_model.rs create mode 100644 src/ui/screens/offboard/mod.rs create mode 100644 src/ui/screens/offboard/view.rs create mode 100644 src/ui/screens/offboard/view_model.rs create mode 100644 src/ui/screens/openpgp/mod.rs create mode 100644 src/ui/screens/openpgp/view.rs create mode 100644 src/ui/screens/openpgp/view_model.rs create mode 100644 src/ui/screens/piv/mod.rs create mode 100644 src/ui/screens/piv/view.rs create mode 100644 src/ui/screens/piv/view_model.rs create mode 100644 src/ui/screens/slots/mod.rs create mode 100644 src/ui/screens/slots/program_form.rs create mode 100644 src/ui/screens/slots/view.rs create mode 100644 src/ui/screens/slots/view_model.rs diff --git a/src/ui/app.rs b/src/ui/app.rs index 5b26e2f..c78cd31 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -9,8 +9,13 @@ use crate::ui::components::sidebar::{AppSidebar, SidebarEvent}; use crate::ui::models::device::{DeviceEvent, DeviceRepo}; use crate::ui::screens::{ - about::AboutViewModel, config::ConfigViewModel, home::HomeViewModel, passkeys::PasskeysEvent, - passkeys::PasskeysViewModel, security::SecurityViewModel, + about::AboutViewModel, accounts::AccountsEvent, accounts::AccountsViewModel, + attestation::AttestationEvent, attestation::AttestationViewModel, audit::AuditViewModel, + backup::BackupViewModel, config::ConfigViewModel, home::HomeViewModel, lock::LockViewModel, + offboard::OffboardEvent, offboard::OffboardViewModel, openpgp::OpenPgpEvent, + openpgp::OpenPgpViewModel, passkeys::PasskeysEvent, + passkeys::PasskeysViewModel, piv::PivEvent, piv::PivViewModel, security::SecurityViewModel, + slots::SlotsEvent, slots::SlotsViewModel, }; use gpui::prelude::*; use gpui::*; @@ -33,6 +38,15 @@ pub struct ViewModelStore { pub about: Option>, pub security: Option>, pub passkeys: Option>, + pub accounts: Option>, + pub slots: Option>, + pub piv: Option>, + pub openpgp: Option>, + pub audit: Option>, + pub backup: Option>, + pub lock: Option>, + pub attestation: Option>, + pub offboard: Option>, pub config: Option>, } @@ -44,6 +58,15 @@ impl ViewModelStore { about: None, security: None, passkeys: None, + accounts: None, + slots: None, + piv: None, + openpgp: None, + audit: None, + backup: None, + lock: None, + attestation: None, + offboard: None, config: None, } } @@ -54,6 +77,15 @@ impl ViewModelStore { pub enum Destination { Home, Passkeys, + Accounts, + Slots, + Piv, + OpenPgp, + Audit, + Backup, + Lock, + Attestation, + Offboard, Configuration, Security, About, @@ -187,6 +219,124 @@ impl Render for ApplicationRoot { }); view.clone().into_any_element() } + Destination::Accounts => { + let view = self.views_store.accounts.get_or_insert_with(|| { + let view = cx.new(|cx| AccountsViewModel::new(window, cx, &self.models)); + cx.subscribe_in( + &view, + window, + |_, _, event: &AccountsEvent, window, cx| match event { + AccountsEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() + } + Destination::Slots => { + let view = self.views_store.slots.get_or_insert_with(|| { + let view = cx.new(|cx| SlotsViewModel::new(window, cx, &self.models)); + cx.subscribe_in( + &view, + window, + |_, _, event: &SlotsEvent, window, cx| match event { + SlotsEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() + } + Destination::Piv => { + let view = self.views_store.piv.get_or_insert_with(|| { + let view = cx.new(|cx| PivViewModel::new(window, cx, &self.models)); + cx.subscribe_in(&view, window, |_, _, event: &PivEvent, window, cx| { + match event { + PivEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + } + }) + .detach(); + view + }); + view.clone().into_any_element() + } + Destination::OpenPgp => { + let view = self.views_store.openpgp.get_or_insert_with(|| { + let view = cx.new(|cx| OpenPgpViewModel::new(window, cx, &self.models)); + cx.subscribe_in( + &view, + window, + |_, _, event: &OpenPgpEvent, window, cx| match event { + OpenPgpEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() + } + Destination::Audit => { + let view = self.views_store.audit.get_or_insert_with(|| { + cx.new(|cx| AuditViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + Destination::Backup => { + let view = self.views_store.backup.get_or_insert_with(|| { + cx.new(|cx| BackupViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + Destination::Lock => { + let view = self.views_store.lock.get_or_insert_with(|| { + cx.new(|cx| LockViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + Destination::Attestation => { + let view = self.views_store.attestation.get_or_insert_with(|| { + let view = cx.new(|cx| AttestationViewModel::new(window, cx, &self.models)); + cx.subscribe_in( + &view, + window, + |_, _, event: &AttestationEvent, window, cx| match event { + AttestationEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() + } + Destination::Offboard => { + let view = self.views_store.offboard.get_or_insert_with(|| { + let view = cx.new(|cx| OffboardViewModel::new(window, cx, &self.models)); + cx.subscribe_in( + &view, + window, + |_, _, event: &OffboardEvent, window, cx| match event { + OffboardEvent::Notification(msg) => { + window.push_notification(msg.to_string(), cx); + } + }, + ) + .detach(); + view + }); + view.clone().into_any_element() + } Destination::Configuration => { let view = self.views_store.config.get_or_insert_with(|| { cx.new(|cx| ConfigViewModel::new(window, cx, &self.models)) diff --git a/src/ui/components/applet_gate.rs b/src/ui/components/applet_gate.rs new file mode 100644 index 0000000..6be0f78 --- /dev/null +++ b/src/ui/components/applet_gate.rs @@ -0,0 +1,44 @@ +//! Shared empty-state gating for the CCID applet screens (Accounts, Slots, +//! PIV, OpenPGP). +//! +//! Three orthogonal questions decide whether a screen can show its content, in +//! priority order: is the CCID interface on, is the applet enabled on the +//! device, does this firmware expose it. Each screen computes an [`AppletGate`] +//! and, unless [`AppletGate::Ready`], renders the message below. + +/// Why an applet screen cannot show its content — or `Ready` to proceed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppletGate { + /// The applet is reachable; render the real UI. + Ready, + /// The CCID / smart-card USB interface is turned off. + CcidOff, + /// The applet is disabled in USB Applications (carries its display name). + Disabled(&'static str), + /// This firmware does not expose the applet. + Unsupported, +} + +impl AppletGate { + /// Heading + body copy for the empty state, or `None` when [`Self::Ready`]. + /// + /// Copy is firmware-neutral by design — it never names "pico-fido". + pub fn message(&self) -> Option<(&'static str, String)> { + match self { + Self::Ready => None, + Self::CcidOff => Some(( + "Smart-card interface off", + "Enable the CCID interface in Configuration → Hardware Endpoints, then reconnect the device." + .into(), + )), + Self::Disabled(name) => Some(( + "Applet disabled", + format!("{name} is turned off. Enable it in Configuration → USB Applications."), + )), + Self::Unsupported => Some(( + "Not available", + "This firmware does not expose this applet.".into(), + )), + } + } +} diff --git a/src/ui/components/form.rs b/src/ui/components/form.rs new file mode 100644 index 0000000..3c267f0 --- /dev/null +++ b/src/ui/components/form.rs @@ -0,0 +1,56 @@ +//! Small form helpers shared by the applet screens' dialogs. + +use gpui::*; +use gpui_component::select::{SelectItem, SelectState}; + +/// A labelled dropdown option carrying a small integer key (a wire value where +/// one exists — algorithm byte, period, digits — or a 0/1 flag otherwise). +#[derive(Clone, PartialEq)] +pub struct LabeledU8 { + label: SharedString, + key: u8, +} + +impl SelectItem for LabeledU8 { + type Value = u8; + fn title(&self) -> SharedString { + self.label.clone() + } + fn value(&self) -> &Self::Value { + &self.key + } +} + +/// Build a `Select` state from `(label, key)` options with a default row. +pub fn select_state( + window: &mut Window, + cx: &mut App, + options: &[(&str, u8)], + default_row: usize, +) -> Entity>> { + let opts: Vec = options + .iter() + .map(|(label, key)| LabeledU8 { + label: (*label).to_string().into(), + key: *key, + }) + .collect(); + cx.new(|cx| { + SelectState::new( + opts, + Some(gpui_component::IndexPath::default().row(default_row)), + window, + cx, + ) + }) +} + +/// Read a select's chosen key by mapping its row back through `options`. +pub fn selected_key( + sel: &Entity>>, + options: &[(&str, u8)], + cx: &App, +) -> u8 { + let row = sel.read(cx).selected_index(cx).map(|p| p.row).unwrap_or(0); + options.get(row).map(|(_, k)| *k).unwrap_or(options[0].1) +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 42ab88e..6d13b5a 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,8 +1,10 @@ //! Reusable UI components built on top of gpui-component primitives. +pub mod applet_gate; pub mod button; pub mod card; pub mod dialog; +pub mod form; pub mod page_view; pub mod sidebar; pub mod tag; diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index f536766..3f41ba9 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -174,16 +174,77 @@ impl Render for AppSidebar { .flex_grow() .bg(sidebar_bg) .border_color(gpui::transparent_white()) + // Grouped so the panel reads as sections, not one long list: the + // device overview, the credential applets, RS-Key's protection + // features, then device-wide system actions (Offboard sits just + // above About as a bottom-of-list decommission action). .child( - SidebarGroup::new("Menu").child( + SidebarGroup::new("Device").child( + SidebarMenu::new().child(self.menu_item( + cx, + "Home", + "icons/house.svg", + Destination::Home, + )), + ), + ) + .child( + SidebarGroup::new("Credentials").child( SidebarMenu::new() - .child(self.menu_item(cx, "Home", "icons/house.svg", Destination::Home)) .child(self.menu_item( cx, "Passkeys", "icons/key-round.svg", Destination::Passkeys, )) + .child(self.menu_item( + cx, + "Accounts", + "icons/key.svg", + Destination::Accounts, + )) + .child(self.menu_item( + cx, + "Slots", + "icons/asterisk.svg", + Destination::Slots, + )) + .child(self.menu_item(cx, "PIV", "icons/shield.svg", Destination::Piv)) + .child(self.menu_item( + cx, + "OpenPGP", + "icons/scroll-text.svg", + Destination::OpenPgp, + )), + ), + ) + .child( + SidebarGroup::new("Protection").child( + SidebarMenu::new() + .child(self.menu_item( + cx, + "Audit", + "icons/book-open.svg", + Destination::Audit, + )) + .child(self.menu_item( + cx, + "Backup", + "icons/save.svg", + Destination::Backup, + )) + .child(self.menu_item(cx, "Lock", "icons/lock.svg", Destination::Lock)) + .child(self.menu_item( + cx, + "Attestation", + "icons/building-2.svg", + Destination::Attestation, + )), + ), + ) + .child( + SidebarGroup::new("System").child( + SidebarMenu::new() .child(self.menu_item( cx, "Configuration", @@ -196,6 +257,12 @@ impl Render for AppSidebar { "icons/shield-check.svg", Destination::Security, )) + .child(self.menu_item( + cx, + "Offboard", + "icons/trash-2.svg", + Destination::Offboard, + )) .child(self.menu_item_icon_name( cx, "About", diff --git a/src/ui/models/device.rs b/src/ui/models/device.rs index 65ed1a2..c9fecab 100644 --- a/src/ui/models/device.rs +++ b/src/ui/models/device.rs @@ -24,6 +24,16 @@ use std::time::Duration; /// triggers a refresh, so this is a detection-latency knob, not a poll cost. const HOTPLUG_POLL_MS: u64 = 1000; +pub use crate::hal::applets::oath; +pub use crate::hal::applets::openpgp; +pub use crate::hal::fido::audit; +pub use crate::hal::fido::backup; +pub use crate::hal::fido::AttStatus; +pub use crate::hal::offboard::OffboardReport; +pub use crate::hal::applets::otp; +pub use crate::hal::io::MgmAuth; +pub use crate::hal::applets::piv; +pub use crate::hal::applets::{OathFeatures, OpenPgpFeatures, OtpFeatures, PivFeatures}; pub use crate::hal::rescue::constants::{ LedColor, LedStatus, USB_CAP_FIDO2, USB_CAP_OATH, USB_CAP_OPENPGP, USB_CAP_OTP, USB_CAP_PIV, USB_CAP_U2F, @@ -33,6 +43,9 @@ pub use types::{ StoredCredential, }; +/// CCID (smart-card) USB interface bit in `AppConfig.enabled_usb_itf`. +const USB_ITF_CCID: u8 = 0x01; + // ── Events ────────────────────────────────────────────────────────────────── /// Events emitted by [`DeviceRepo`] to notify subscribers of state changes. @@ -91,6 +104,338 @@ impl DeviceRepo { AnyFirmware::new(fw_type.clone(), version).supports_legacy_fido_hardware_config() } + // ── OATH (Accounts) blocking wrappers ───────────────────────────────── + + pub fn oath_password_required_blocking() -> Result { + io::oath_password_required() + } + + pub fn oath_list_accounts_blocking( + password: Option, + ) -> Result, crate::error::PFError> { + io::oath_list_accounts(password) + } + + pub fn oath_add_blocking( + password: Option, + cred: oath::NewCredential, + ) -> Result<(), crate::error::PFError> { + io::oath_add(password, cred) + } + + pub fn oath_delete_blocking( + password: Option, + id: String, + ) -> Result<(), crate::error::PFError> { + io::oath_delete(password, id) + } + + pub fn oath_rename_blocking( + password: Option, + old_id: String, + new_id: String, + ) -> Result<(), crate::error::PFError> { + io::oath_rename(password, old_id, new_id) + } + + pub fn oath_calculate_blocking( + password: Option, + id: String, + period: u32, + ) -> Result { + io::oath_calculate(password, id, period) + } + + pub fn oath_set_password_blocking( + current: Option, + new_password: Option, + ) -> Result<(), crate::error::PFError> { + io::oath_set_password(current, new_password) + } + + pub fn oath_reset_blocking() -> Result<(), crate::error::PFError> { + io::oath_reset() + } + + // ── OTP (Slots) blocking wrappers ───────────────────────────────────── + + pub fn otp_read_info_blocking() -> Result<[otp::SlotInfo; 4], crate::error::PFError> { + io::otp_read_info() + } + + pub fn otp_program_chalresp_blocking( + slot: u8, + secret: Vec, + touch: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], + ) -> Result<(), crate::error::PFError> { + io::otp_program_chalresp(slot, secret, touch, new_acc, current_acc) + } + + pub fn otp_program_hotp_blocking( + slot: u8, + secret: Vec, + digits8: bool, + append_cr: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], + ) -> Result<(), crate::error::PFError> { + io::otp_program_hotp(slot, secret, digits8, append_cr, new_acc, current_acc) + } + + pub fn otp_program_static_blocking( + slot: u8, + scancodes: Vec, + append_cr: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], + ) -> Result<(), crate::error::PFError> { + io::otp_program_static(slot, scancodes, append_cr, new_acc, current_acc) + } + + #[allow(clippy::too_many_arguments)] + pub fn otp_program_yubico_blocking( + slot: u8, + public_id: Vec, + private_id: [u8; 6], + key: [u8; 16], + append_cr: bool, + new_acc: [u8; 6], + current_acc: [u8; 6], + ) -> Result<(), crate::error::PFError> { + io::otp_program_yubico(slot, public_id, private_id, key, append_cr, new_acc, current_acc) + } + + pub fn otp_delete_blocking(slot: u8, current_acc: [u8; 6]) -> Result<(), crate::error::PFError> { + io::otp_delete(slot, current_acc) + } + + pub fn otp_swap_blocking(current_acc: [u8; 6]) -> Result<(), crate::error::PFError> { + io::otp_swap(current_acc) + } + + pub fn otp_calculate_blocking( + slot: u8, + challenge: Vec, + ) -> Result, crate::error::PFError> { + io::otp_calculate(slot, challenge) + } + + // ── PIV blocking wrappers ───────────────────────────────────────────── + + pub fn piv_read_info_blocking() -> Result { + io::piv_read_info() + } + + pub fn piv_change_pin_blocking(old: String, new: String) -> Result<(), crate::error::PFError> { + io::piv_change_pin(old, new) + } + + pub fn piv_change_puk_blocking(old: String, new: String) -> Result<(), crate::error::PFError> { + io::piv_change_puk(old, new) + } + + pub fn piv_unblock_pin_blocking( + puk: String, + new_pin: String, + ) -> Result<(), crate::error::PFError> { + io::piv_unblock_pin(puk, new_pin) + } + + #[allow(clippy::too_many_arguments)] + pub fn piv_generate_blocking( + slot: u8, + algo: u8, + pin_policy: u8, + touch_policy: u8, + auth: MgmAuth, + ) -> Result, crate::error::PFError> { + io::piv_generate(slot, algo, pin_policy, touch_policy, auth) + } + + pub fn piv_export_cert_blocking(slot: u8) -> Result, crate::error::PFError> { + io::piv_export_cert(slot) + } + + pub fn piv_import_cert_blocking( + slot: u8, + der: Vec, + auth: MgmAuth, + ) -> Result<(), crate::error::PFError> { + io::piv_import_cert(slot, der, auth) + } + + pub fn piv_attest_blocking(slot: u8) -> Result, crate::error::PFError> { + io::piv_attest(slot) + } + + pub fn piv_move_key_blocking( + src: u8, + dst: u8, + auth: MgmAuth, + ) -> Result<(), crate::error::PFError> { + io::piv_move_key(src, dst, auth) + } + + pub fn piv_delete_key_blocking(slot: u8, auth: MgmAuth) -> Result<(), crate::error::PFError> { + io::piv_delete_key(slot, auth) + } + + pub fn piv_import_key_blocking( + slot: u8, + key_file: Vec, + auth: MgmAuth, + ) -> Result<(), crate::error::PFError> { + io::piv_import_key(slot, key_file, auth) + } + + pub fn piv_delete_cert_blocking( + slot: u8, + auth: MgmAuth, + ) -> Result<(), crate::error::PFError> { + io::piv_delete_cert(slot, auth) + } + + pub fn piv_set_mgm_blocking( + current: MgmAuth, + new_algo: u8, + new_key: Vec, + touch: bool, + ) -> Result<(), crate::error::PFError> { + io::piv_set_mgm(current, new_algo, new_key, touch) + } + + pub fn piv_set_retries_blocking( + auth: MgmAuth, + pin: String, + pin_tries: u8, + puk_tries: u8, + ) -> Result<(), crate::error::PFError> { + io::piv_set_retries(auth, pin, pin_tries, puk_tries) + } + + pub fn piv_reset_blocking() -> Result<(), crate::error::PFError> { + io::piv_reset() + } + + // ── OpenPGP blocking wrappers ───────────────────────────────────────── + + pub fn openpgp_read_info_blocking() -> Result { + io::openpgp_read_info() + } + + pub fn openpgp_change_user_pin_blocking( + old: String, + new: String, + ) -> Result<(), crate::error::PFError> { + io::openpgp_change_user_pin(old, new) + } + + pub fn openpgp_change_admin_pin_blocking( + old: String, + new: String, + ) -> Result<(), crate::error::PFError> { + io::openpgp_change_admin_pin(old, new) + } + + pub fn openpgp_unblock_with_code_blocking( + rc: String, + new_pin: String, + ) -> Result<(), crate::error::PFError> { + io::openpgp_unblock_with_code(rc, new_pin) + } + + pub fn openpgp_unblock_with_admin_blocking( + admin: String, + new_pin: String, + ) -> Result<(), crate::error::PFError> { + io::openpgp_unblock_with_admin(admin, new_pin) + } + + pub fn openpgp_set_reset_code_blocking( + admin: String, + new_rc: String, + ) -> Result<(), crate::error::PFError> { + io::openpgp_set_reset_code(admin, new_rc) + } + + pub fn openpgp_set_cardholder_blocking( + admin: String, + name: String, + login: String, + url: String, + lang: String, + sex: u8, + ) -> Result<(), crate::error::PFError> { + io::openpgp_set_cardholder(admin, name, login, url, lang, sex) + } + + pub fn openpgp_set_touch_blocking( + admin: String, + slot: openpgp::PgpSlot, + on: bool, + ) -> Result<(), crate::error::PFError> { + io::openpgp_set_touch(admin, slot, on) + } + + pub fn openpgp_generate_blocking( + admin: String, + slot: openpgp::PgpSlot, + choice: u8, + ) -> Result<(), crate::error::PFError> { + io::openpgp_generate(admin, slot, choice) + } + + pub fn openpgp_reset_blocking() -> Result<(), crate::error::PFError> { + io::openpgp_reset() + } + + // ── Applet gating (read already-held device state) ──────────────────── + + /// OATH feature profile of the connected firmware, if it exposes the applet. + pub fn oath_features(&self) -> Option { + let status = self.status.as_ref()?; + AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).oath_features() + } + + /// OTP feature profile of the connected firmware, if it exposes the applet. + pub fn otp_features(&self) -> Option { + let status = self.status.as_ref()?; + AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).otp_features() + } + + /// PIV feature profile of the connected firmware, if it exposes the applet. + pub fn piv_features(&self) -> Option { + let status = self.status.as_ref()?; + AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).piv_features() + } + + /// OpenPGP feature profile of the connected firmware, if it exposes the applet. + pub fn openpgp_features(&self) -> Option { + let status = self.status.as_ref()?; + AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version) + .openpgp_features() + } + + /// Whether an applet capability bit is enabled in USB Applications. Lenient + /// when the mask is unknown — the SELECT then gives the authoritative answer. + pub fn applet_enabled(&self, cap: u16) -> bool { + self.management_apps + .as_ref() + .map(|m| m.usb_enabled & cap != 0) + .unwrap_or(true) + } + + /// Whether the CCID/smart-card USB interface is on (lenient when unknown). + pub fn ccid_on(&self) -> bool { + self.status + .as_ref() + .and_then(|s| s.config.enabled_usb_itf) + .map(|m| m & USB_ITF_CCID != 0) + .unwrap_or(true) + } + pub fn read_device_state_blocking() -> Result { let status = io::read_device_details()?; let (led_status, management_apps) = if status.firmware_type == types::FirmwareType::RSKey { @@ -108,28 +453,14 @@ impl DeviceRepo { }) } - pub fn write_config_blocking( - config: types::AppConfigInput, - method: types::DeviceMethod, - pin: Option, - ) -> Result { - io::write_config(config, method, pin) - } - - pub fn write_led_config_blocking( + pub fn write_all_config_blocking( method: DeviceMethod, - config: LedStatusConfig, + phy: Option, + led: Option, + apps: Option, pin: Option, ) -> Result { - io::write_led_config(method, config, pin) - } - - pub fn write_management_config_blocking( - method: DeviceMethod, - enabled_mask: u16, - pin: Option, - ) -> Result { - io::write_management_config(method, enabled_mask, pin) + io::write_all_config(method, phy, led, apps, pin) } pub fn get_fido_info_blocking() -> Result { @@ -177,6 +508,83 @@ impl DeviceRepo { io::reset_device() } + // ── Audit journal blocking wrappers ─────────────────────────────────── + + pub fn audit_log_blocking(pin: Option) -> Result { + io::audit_log(pin) + } + + pub fn audit_verify_blocking( + pin: Option, + expect_key: Option, + ) -> Result { + io::audit_verify(pin, expect_key) + } + + pub fn audit_status_blocking() -> Result { + io::audit_status() + } + + pub fn audit_set_enabled_blocking(on: bool, pin: Option) -> Result { + io::audit_set_enabled(on, pin) + } + + // ── Seed backup blocking wrappers ───────────────────────────────────── + + pub fn backup_status_blocking() -> Result { + io::backup_status() + } + + pub fn backup_finalize_blocking() -> Result<(), String> { + io::backup_finalize() + } + + pub fn backup_export_blocking(pin: Option) -> Result { + io::backup_export(pin) + } + + pub fn backup_restore_blocking(pin: Option, mnemonic: String) -> Result<(), String> { + io::backup_restore(pin, mnemonic) + } + + // ── At-rest soft lock blocking wrappers ─────────────────────────────── + + pub fn lock_enable_blocking(pin: String) -> Result { + io::lock_enable(pin) + } + + pub fn lock_unlock_blocking(mnemonic: String) -> Result<(), String> { + io::lock_unlock(mnemonic) + } + + pub fn lock_disable_blocking(pin: String, mnemonic: String) -> Result<(), String> { + io::lock_disable(pin, mnemonic) + } + + // ── Org attestation blocking wrappers ───────────────────────────────── + + pub fn att_status_blocking() -> Result { + io::att_status() + } + + pub fn att_clear_blocking(pin: Option) -> Result<(), String> { + io::att_clear(pin) + } + + pub fn att_import_blocking( + pin: Option, + key_file: Vec, + chain_file: Vec, + ) -> Result<(), String> { + io::att_import(pin, key_file, chain_file) + } + + // ── Offboard blocking wrapper ───────────────────────────────────────── + + pub fn offboard_blocking(serial: String) -> Result { + io::offboard(serial) + } + pub fn read_device_serial_blocking() -> Option { io::read_device_details().ok().map(|s| s.info.serial) } diff --git a/src/ui/screens/accounts/mod.rs b/src/ui/screens/accounts/mod.rs new file mode 100644 index 0000000..5494b63 --- /dev/null +++ b/src/ui/screens/accounts/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::{AccountsEvent, AccountsViewModel}; diff --git a/src/ui/screens/accounts/view.rs b/src/ui/screens/accounts/view.rs new file mode 100644 index 0000000..bb4824e --- /dev/null +++ b/src/ui/screens/accounts/view.rs @@ -0,0 +1,292 @@ +//! Accounts (OATH) screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::models::device::oath; +use crate::ui::screens::accounts::view_model::AccountsViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +/// Split a numeric code into two halves for readability ("123 456"). +fn format_code(code: &str) -> String { + if code.len() >= 6 { + let mid = code.len() / 2; + format!("{} {}", &code[..mid], &code[mid..]) + } else { + code.to_string() + } +} + +fn seconds_left(now: u64, period: u32) -> u32 { + let p = period.max(1) as u64; + (p - (now % p)) as u32 +} + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) + .into_any_element() +} + +impl AccountsViewModel { + fn render_account_row( + &self, + acc: &oath::Account, + now: u64, + can_rename: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let issuer = acc.issuer.clone().unwrap_or_default(); + let account = acc.account.clone(); + let id = acc.id.clone(); + let period = acc.period; + + let (primary, secondary) = if issuer.is_empty() { + (account.clone(), String::new()) + } else { + (issuer, account) + }; + + let identity = v_flex() + .gap_0p5() + .child(div().font_medium().child(primary)) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(secondary), + ); + + let acc_for_rename = acc.clone(); + let rename_btn = can_rename.then(|| { + Button::new(SharedString::from(format!("ren-{id}"))) + .icon(Icon::default().path("icons/tag.svg")) + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.open_rename_dialog(&acc_for_rename, window, cx); + })) + }); + let acc_for_delete = acc.clone(); + let delete_btn = Button::new(SharedString::from(format!("del-{id}"))) + .icon(Icon::default().path("icons/trash-2.svg")) + .ghost() + .on_click(cx.listener(move |this, _, window, cx| { + this.open_delete_dialog(&acc_for_delete, window, cx); + })); + + let right = match &acc.state { + oath::CodeState::Code { value, period: p } => { + let code = value.clone(); + let rem = seconds_left(now, *p); + let copy_code = code.clone(); + h_flex() + .gap_3() + .items_center() + .child( + div() + .font_family("Mono") + .text_xl() + .text_color(theme.foreground) + .child(format_code(&code)), + ) + .child( + div() + .w_8() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!("{rem}s")), + ) + .child( + Button::new(SharedString::from(format!("copy-{id}"))) + .icon(Icon::default().path("icons/copy.svg")) + .ghost() + .on_click(cx.listener(move |this, _, _, cx| { + this.copy_code(copy_code.clone(), cx); + })), + ) + .children(rename_btn) + .child(delete_btn) + } + oath::CodeState::Hotp => h_flex() + .gap_3() + .items_center() + .child( + Button::new(SharedString::from(format!("calc-{id}"))) + .label("Generate") + .outline() + .on_click(cx.listener(move |this, _, _, cx| { + this.calculate(id.clone(), period, cx); + })), + ) + .children(rename_btn) + .child(delete_btn), + oath::CodeState::Touch => h_flex() + .gap_3() + .items_center() + .child( + Button::new(SharedString::from(format!("touch-{id}"))) + .label("Touch to reveal") + .outline() + .on_click(cx.listener(move |this, _, _, cx| { + this.calculate(id.clone(), period, cx); + })), + ) + .children(rename_btn) + .child(delete_btn), + }; + + h_flex() + .justify_between() + .items_center() + .p_3() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child(identity) + .child(right) + .into_any_element() + } +} + +impl Render for AccountsViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Accounts"; + const SUBTITLE: &str = "One-time password accounts (OATH)."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + if self.needs_password && !self.loaded { + let unlock = Button::new("unlock-oath") + .icon(Icon::default().path("icons/lock-open.svg")) + .label("Unlock") + .primary() + .on_click(cx.listener(|this, _, window, cx| this.open_unlock_dialog(window, cx))); + let theme = cx.theme(); + let card = Card::new() + .title("Accounts") + .description("Password-protected") + .icon(Icon::default().path("icons/key.svg")) + .child( + v_flex() + .items_center() + .justify_center() + .gap_3() + .py_6() + .child(div().font_semibold().child("Accounts are password-protected")) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Enter the OATH password to view your codes."), + ) + .child(unlock), + ); + return PageView::build(TITLE, SUBTITLE, card, theme).into_any_element(); + } + + // Build rows first (mutable cx), then the chrome. + let accounts = self.accounts.clone(); + let now = self.now; + let can_rename = self + .device + .read(cx) + .oath_features() + .map(|f| f.rename) + .unwrap_or(false); + let mut rows = Vec::with_capacity(accounts.len()); + for acc in &accounts { + rows.push(self.render_account_row(acc, now, can_rename, cx)); + } + + let password_label = if self.password_is_set() { + "Change password" + } else { + "Set password" + }; + let password_btn = Button::new("password-oath") + .icon(Icon::default().path("icons/lock.svg")) + .label(password_label) + .ghost() + .on_click(cx.listener(|this, _, window, cx| this.open_password_dialog(window, cx))); + let refresh_btn = Button::new("refresh-oath") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + let add_btn = Button::new("add-account") + .icon(Icon::default().path("icons/plus.svg")) + .label("Add account") + .primary() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_add_dialog(window, cx))); + let reset_btn = Button::new("reset-oath") + .label("Reset OATH applet") + .danger() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_reset_dialog(window, cx))); + + let theme = cx.theme(); + let toolbar = h_flex() + .gap_2() + .child(password_btn) + .child(refresh_btn) + .child(add_btn); + let list = if rows.is_empty() { + empty_state( + "No accounts yet", + "Add an account from an otpauth:// URI or a base32 secret.".into(), + theme, + ) + } else { + v_flex().gap_2().children(rows).into_any_element() + }; + + let accounts_card = Card::new() + .title("Accounts") + .description(format!("{} stored", accounts.len())) + .icon(Icon::default().path("icons/key.svg")) + .header_right(toolbar) + .child(list); + let reset_card = Card::new() + .title("Reset") + .description("Erase all accounts and the OATH password") + .icon(Icon::default().path("icons/trash.svg")) + .child( + h_flex() + .items_center() + .justify_between() + .child( + v_flex() + .gap_1() + .child(div().font_medium().child("Reset OATH applet")) + .child(div().text_sm().text_color(theme.muted_foreground).child( + "Deletes every account and the password. Cannot be undone.", + )), + ) + .child(reset_btn), + ); + + let content = v_flex().gap_6().child(accounts_card).child(reset_card); + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/accounts/view_model.rs b/src/ui/screens/accounts/view_model.rs new file mode 100644 index 0000000..997e827 --- /dev/null +++ b/src/ui/screens/accounts/view_model.rs @@ -0,0 +1,763 @@ +//! View model for the Accounts (OATH) screen — TOTP/HOTP credential +//! management over the CCID OATH applet. + +use crate::error::PFError; +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::{ConfirmContent, PinPromptContent}; +use crate::ui::models::device::{oath, DeviceEvent, DeviceRepo, USB_CAP_OATH}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use crate::ui::components::form::{select_state, selected_key, LabeledU8}; +use gpui_component::select::SelectState; +use gpui_component::WindowExt; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// Add-form dropdown options (label, key). The key is the wire value where one +// exists (algorithm byte, period seconds, digit count) or a 0/1 flag otherwise. +const OPT_TYPE: &[(&str, u8)] = &[("Time-based (TOTP)", 0), ("Counter-based (HOTP)", 1)]; +const OPT_ALGO: &[(&str, u8)] = &[("SHA-1", 1), ("SHA-256", 2), ("SHA-512", 3)]; +const OPT_PERIOD: &[(&str, u8)] = &[ + ("20 seconds", 20), + ("30 seconds", 30), + ("45 seconds", 45), + ("60 seconds", 60), +]; +const OPT_DIGITS: &[(&str, u8)] = &[("6 digits", 6), ("8 digits", 8)]; +const OPT_TOUCH: &[(&str, u8)] = &[("Not required", 0), ("Touch required", 1)]; + +pub fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Accounts screen state, code refresh, and OATH operations. +pub struct AccountsViewModel { + pub(super) device: Entity, + pub(super) accounts: Vec, + pub(super) loaded: bool, + pub(super) needs_password: bool, + password: Option, + pub(super) loading: bool, + /// Current unix time, ticked once a second to drive the countdown + refresh. + pub(super) now: u64, + last_window: u64, + _task: Option>, + _ticker: Option>, +} + +/// UI-level notifications surfaced by the parent as window toasts. +pub enum AccountsEvent { + Notification(String), +} + +impl EventEmitter for AccountsViewModel {} + +impl AccountsViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + this.on_device_event(cx); + }) + .detach(); + + let mut this = Self { + device, + accounts: Vec::new(), + loaded: false, + needs_password: false, + password: None, + loading: false, + now: now_unix(), + last_window: 0, + _task: None, + _ticker: None, + }; + this.start_ticker(cx); + this.try_initial_load(cx); + this + } + + /// A device (un)plug: re-lock and try to (re)load for the new device. + fn on_device_event(&mut self, cx: &mut Context) { + if self.device.read(cx).device_changed { + self.accounts.clear(); + self.loaded = false; + self.needs_password = false; + self.password = None; + } + self.try_initial_load(cx); + cx.notify(); + } + + fn start_ticker(&mut self, cx: &mut Context) { + self._ticker = Some(cx.spawn(async move |weak, cx| loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let alive = weak.update(cx, |this, cx| { + this.now = now_unix(); + if this.loaded && !this.loading && this.now / 30 != this.last_window { + this.reload(cx); + } + cx.notify(); + }); + if alive.is_err() { + break; + } + })); + } + + /// The gate deciding whether the screen can show accounts. + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + if repo.status.is_none() { + return AppletGate::Unsupported; + } + match repo.oath_features() { + None => AppletGate::Unsupported, + Some(_) if !repo.ccid_on() => AppletGate::CcidOff, + Some(_) if !repo.applet_enabled(USB_CAP_OATH) => AppletGate::Disabled("OATH"), + Some(_) => AppletGate::Ready, + } + } + + fn try_initial_load(&mut self, cx: &mut Context) { + if self.loaded || self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let required = cx + .background_executor() + .spawn(async { DeviceRepo::oath_password_required_blocking() }) + .await; + match required { + Ok(true) => { + let _ = weak.update(cx, |this, cx| { + this.needs_password = true; + this.loading = false; + cx.notify(); + }); + } + Ok(false) => { + let list = cx + .background_executor() + .spawn(async { DeviceRepo::oath_list_accounts_blocking(None) }) + .await; + let _ = weak.update(cx, |this, cx| this.apply_load(list, None, cx)); + } + Err(e) => { + let _ = weak.update(cx, |this, cx| { + this.loading = false; + log::warn!("OATH probe failed: {e}"); + cx.notify(); + }); + } + } + })); + } + + fn apply_load( + &mut self, + result: Result, PFError>, + password: Option, + cx: &mut Context, + ) { + self.loading = false; + match result { + Ok(accounts) => { + self.accounts = accounts; + self.loaded = true; + self.needs_password = false; + self.last_window = self.now / 30; + if password.is_some() { + self.password = password; + } + } + Err(e) => { + log::warn!("OATH load failed: {e}"); + cx.emit(AccountsEvent::Notification(format!("Accounts: {e}"))); + } + } + cx.notify(); + } + + /// Re-fetch codes with the cached password (used by the ticker + after ops). + fn reload(&mut self, cx: &mut Context) { + if self.loading { + return; + } + self.loading = true; + self.last_window = self.now / 30; + cx.notify(); + let pw = self.password.clone(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let list = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_list_accounts_blocking(pw) }) + .await; + let _ = weak.update(cx, |this, cx| this.apply_load(list, None, cx)); + })); + } + + pub(super) fn open_unlock_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view = cx.entity().downgrade(); + dialog::open_pin_prompt( + "Unlock Accounts", + "Enter the OATH password for this device.", + None, + "Unlock", + window, + cx, + move |password, dialog_handle, cx| { + let _ = view.update(cx, |this, cx| this.unlock(password, dialog_handle, cx)); + }, + ); + } + + fn unlock( + &mut self, + password: String, + dh: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let pw = password.clone(); + let list = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_list_accounts_blocking(Some(pw)) }) + .await; + let _ = weak.update(cx, |this, cx| { + match &list { + Ok(_) => { + let _ = dh.update(cx, |d, cx| d.set_success("Unlocked.".into(), cx)); + } + Err(e) => { + let _ = dh.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + this.apply_load(list, Some(password), cx); + }); + })); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.reload(cx); + } + + /// Whether the applet currently has an access code set. + pub(super) fn password_is_set(&self) -> bool { + self.needs_password || self.password.is_some() + } + + pub(super) fn open_password_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let current = self.password.clone(); + let view = cx.entity().downgrade(); + dialog::open_pin_prompt( + "OATH Password", + "Enter a new OATH password, or leave empty to remove password protection.", + None, + "Save", + window, + cx, + move |new_password, dialog_handle, cx| { + let _ = view.update(cx, |this, cx| { + this.set_password(current.clone(), new_password, dialog_handle, cx) + }); + }, + ); + } + + fn set_password( + &mut self, + current: Option, + new_password: String, + dh: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let new = (!new_password.trim().is_empty()).then(|| new_password.trim().to_string()); + let new_cache = new.clone(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_set_password_blocking(current, new) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + this.password = new_cache; + let msg = if this.password.is_some() { + "Password saved." + } else { + "Password removed." + }; + let _ = dh.update(cx, |d, cx| d.set_success(msg.into(), cx)); + this.reload(cx); + } + Err(e) => { + let _ = dh.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn copy_code(&self, code: String, cx: &mut Context) { + cx.write_to_clipboard(ClipboardItem::new_string(code)); + cx.emit(AccountsEvent::Notification("Code copied".into())); + } + + /// Compute a single credential's code on demand (HOTP or touch-gated). + pub(super) fn calculate(&mut self, id: String, period: u32, cx: &mut Context) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let pw = self.password.clone(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let id_bg = id.clone(); + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_calculate_blocking(pw, id_bg, period) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(code) => { + if let Some(acc) = this.accounts.iter_mut().find(|a| a.id == id) { + acc.state = oath::CodeState::Code { value: code, period }; + } + } + Err(e) => cx.emit(AccountsEvent::Notification(format!("Calculate: {e}"))), + } + cx.notify(); + }); + })); + } + + pub(super) fn open_add_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let issuer = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).placeholder("Issuer (e.g. GitHub)") + }); + let account = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).placeholder("Account (e.g. you@example.com)") + }); + let secret = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Base32 secret, or paste an otpauth:// URI") + }); + let type_sel = select_state(window, cx, OPT_TYPE, 0); + let algo_sel = select_state(window, cx, OPT_ALGO, 0); + let period_sel = select_state(window, cx, OPT_PERIOD, 1); // default 30 s + let digits_sel = select_state(window, cx, OPT_DIGITS, 0); + let touch_sel = select_state(window, cx, OPT_TOUCH, 0); + + let view = cx.entity().downgrade(); + let submit = { + let issuer = issuer.clone(); + let account = account.clone(); + let secret = secret.clone(); + let type_sel = type_sel.clone(); + let algo_sel = algo_sel.clone(); + let period_sel = period_sel.clone(); + let digits_sel = digits_sel.clone(); + let touch_sel = touch_sel.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let secret_v = secret.read(cx).text().to_string().trim().to_string(); + if secret_v.is_empty() { + return; + } + let parsed = if secret_v.starts_with("otpauth://") { + // A pasted URI carries every field itself; the form is ignored. + oath::parse_otpauth(&secret_v) + } else { + let account_v = account.read(cx).text().to_string().trim().to_string(); + if account_v.is_empty() { + Err("Enter an account name".to_string()) + } else { + match oath::base32_decode(&secret_v).filter(|s| !s.is_empty()) { + None => Err("Invalid base32 secret".to_string()), + Some(bytes) => { + let issuer_v = + issuer.read(cx).text().to_string().trim().to_string(); + let oath_type = if selected_key(&type_sel, OPT_TYPE, cx) == 1 { + oath::OathType::Hotp + } else { + oath::OathType::Totp + }; + let algorithm = match selected_key(&algo_sel, OPT_ALGO, cx) { + 2 => oath::HashAlgo::Sha256, + 3 => oath::HashAlgo::Sha512, + _ => oath::HashAlgo::Sha1, + }; + Ok(oath::NewCredential { + issuer: (!issuer_v.is_empty()).then_some(issuer_v), + account: account_v, + secret: bytes, + oath_type, + algorithm, + digits: selected_key(&digits_sel, OPT_DIGITS, cx), + period: selected_key(&period_sel, OPT_PERIOD, cx) as u32, + counter: 0, + touch: selected_key(&touch_sel, OPT_TOUCH, cx) == 1, + }) + } + } + } + }; + match parsed { + Ok(cred) => { + window.close_dialog(cx); + let status = + dialog::open_status_dialog("Adding Account", window, cx); + let _ = view.update(cx, |this, cx| this.execute_add(cred, status, cx)); + } + Err(e) => { + let _ = view.update(cx, |_, cx| { + cx.emit(AccountsEvent::Notification(e)); + }); + } + } + }) + }; + + window.open_dialog(cx, move |dialog, _window, _| { + let issuer = issuer.clone(); + let account = account.clone(); + let secret = secret.clone(); + let type_sel = type_sel.clone(); + let algo_sel = algo_sel.clone(); + let period_sel = period_sel.clone(); + let digits_sel = digits_sel.clone(); + let touch_sel = touch_sel.clone(); + let submit_ok = submit.clone(); + let submit_btn = submit.clone(); + let field = |label: &str, sel: &Entity>>| { + gpui_component::v_flex() + .gap_1() + .flex_1() + .child(label.to_string()) + .child( + gpui_component::select::Select::new(sel) + .w_full() + .bg(rgb(0x222225)), + ) + }; + dialog + .title("Add Account") + .child("Fill in the details, or paste an otpauth:// URI into the secret field.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child("Issuer") + .child(gpui_component::input::Input::new(&issuer)) + .child("Account") + .child(gpui_component::input::Input::new(&account)) + .child("Secret") + .child(gpui_component::input::Input::new(&secret)) + .child( + gpui_component::h_flex() + .gap_3() + .child(field("Account type", &type_sel)) + .child(field("Algorithm", &algo_sel)), + ) + .child( + gpui_component::h_flex() + .gap_3() + .child(field("Period (time-based)", &period_sel)) + .child(field("Digits", &digits_sel)), + ) + .child(field("Touch", &touch_sel)), + ) + .on_ok(move |_, window, cx| { + submit_ok(window, cx); + false + }) + .footer(move |_, _window, _cx, _| { + let submit = submit_btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("add") + .primary() + .label("Add") + .on_click(move |_, window, cx| submit(window, cx)), + ] + }) + }); + } + + fn execute_add( + &mut self, + cred: oath::NewCredential, + status: WeakEntity, + cx: &mut Context, + ) { + self.loading = true; + cx.notify(); + let pw = self.password.clone(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_add_blocking(pw, cred) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success("Account added.".into(), cx)); + this.reload(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn open_rename_dialog( + &mut self, + acc: &oath::Account, + window: &mut Window, + cx: &mut Context, + ) { + let old_id = acc.id.clone(); + let oath_type = acc.oath_type; + let period = acc.period; + let issuer = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Issuer") + .default_value(acc.issuer.clone().unwrap_or_default()) + }); + let account = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Account") + .default_value(acc.account.clone()) + }); + + let view = cx.entity().downgrade(); + let submit = { + let issuer = issuer.clone(); + let account = account.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let new_issuer = issuer.read(cx).text().to_string(); + let new_account = account.read(cx).text().to_string(); + let acct = new_account.trim(); + if acct.is_empty() { + return; + } + let iss = new_issuer.trim(); + let new_id = + oath::build_cred_id((!iss.is_empty()).then_some(iss), acct, oath_type, period); + window.close_dialog(cx); + if new_id == old_id { + return; + } + let status = dialog::open_status_dialog("Renaming Account", window, cx); + let old = old_id.clone(); + let _ = view.update(cx, |this, cx| this.execute_rename(old, new_id, status, cx)); + }) + }; + + window.open_dialog(cx, move |dialog, _window, _| { + let issuer = issuer.clone(); + let account = account.clone(); + let submit_ok = submit.clone(); + let submit_btn = submit.clone(); + dialog + .title("Rename Account") + .child("Change the issuer and account name.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child("Issuer") + .child(gpui_component::input::Input::new(&issuer)) + .child("Account") + .child(gpui_component::input::Input::new(&account)), + ) + .on_ok(move |_, window, cx| { + submit_ok(window, cx); + false + }) + .footer(move |_, _window, _cx, _| { + let submit = submit_btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("rename") + .primary() + .label("Rename") + .on_click(move |_, window, cx| submit(window, cx)), + ] + }) + }); + } + + fn execute_rename( + &mut self, + old_id: String, + new_id: String, + status: WeakEntity, + cx: &mut Context, + ) { + self.loading = true; + cx.notify(); + let pw = self.password.clone(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_rename_blocking(pw, old_id, new_id) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success("Account renamed.".into(), cx)); + this.reload(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn open_delete_dialog( + &mut self, + acc: &oath::Account, + window: &mut Window, + cx: &mut Context, + ) { + let id = acc.id.clone(); + let label = acc.issuer.clone().unwrap_or_else(|| acc.account.clone()); + let view = cx.entity().downgrade(); + dialog::open_confirm( + "Delete Account", + format!("Delete the account \"{label}\"? This cannot be undone."), + "Delete", + gpui_component::button::ButtonVariant::Danger, + window, + cx, + move |dialog_handle, _, cx| { + let _ = view.update(cx, |this, cx| { + this.execute_delete(id.clone(), dialog_handle, cx) + }); + }, + ); + } + + fn execute_delete( + &mut self, + id: String, + dh: WeakEntity, + cx: &mut Context, + ) { + self.loading = true; + cx.notify(); + let pw = self.password.clone(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::oath_delete_blocking(pw, id) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = dh.update(cx, |d, cx| d.set_success("Account deleted.".into(), cx)); + this.reload(cx); + } + Err(e) => { + let _ = dh.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn open_reset_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view = cx.entity().downgrade(); + dialog::open_confirm( + "Reset OATH Applet", + "This permanently deletes ALL accounts and the OATH password. This cannot be undone." + .to_string(), + "Reset", + gpui_component::button::ButtonVariant::Danger, + window, + cx, + move |_dialog_handle, window, cx| { + window.close_dialog(cx); + let _ = view.update(cx, |this, cx| this.execute_reset(window, cx)); + }, + ); + } + + fn execute_reset(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let status = dialog::open_status_dialog("Resetting OATH Applet", window, cx); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::oath_reset_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| { + d.set_success("OATH applet reset.".into(), cx) + }); + this.accounts.clear(); + this.loaded = false; + this.password = None; + this.try_initial_load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } +} diff --git a/src/ui/screens/attestation/mod.rs b/src/ui/screens/attestation/mod.rs new file mode 100644 index 0000000..1c4f337 --- /dev/null +++ b/src/ui/screens/attestation/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::{AttestationEvent, AttestationViewModel}; diff --git a/src/ui/screens/attestation/view.rs b/src/ui/screens/attestation/view.rs new file mode 100644 index 0000000..c8bf9ea --- /dev/null +++ b/src/ui/screens/attestation/view.rs @@ -0,0 +1,143 @@ +//! Attestation screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::screens::attestation::view_model::AttestationViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body)) + .into_any_element() +} + +impl AttestationViewModel { + fn action_row( + &self, + title: &'static str, + subtitle: &'static str, + btn: Button, + theme: &Theme, + ) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + ) + .child(btn) + } +} + +impl Render for AttestationViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Attestation"; + const SUBTITLE: &str = "Organisation (enterprise) attestation key and chain."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let status = self.status.clone(); + let installed = status.as_ref().map(|s| s.installed).unwrap_or(false); + + let refresh_btn = Button::new("att-refresh") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + let import_btn = Button::new("att-import") + .label(if installed { "Replace" } else { "Import" }) + .outline() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_import(window, cx))); + let clear_btn = Button::new("att-clear") + .label("Remove") + .danger() + .disabled(self.loading || !installed) + .on_click(cx.listener(|this, _, window, cx| this.open_clear(window, cx))); + + let theme = cx.theme(); + + let status_card = { + let body = match &status { + Some(s) => { + let mut col = v_flex().gap_2().child( + h_flex() + .gap_2() + .items_center() + .child(div().text_color(if s.installed { theme.green } else { theme.muted_foreground }).child("●")) + .child(div().text_sm().child(if s.installed { + "Org attestation installed" + } else { + "Not installed — self-signed device certificate in use" + })), + ); + if let Some(h) = &s.chain_hash { + col = col.child( + v_flex() + .gap_0p5() + .child(div().text_xs().text_color(theme.muted_foreground).child("Chain hash")) + .child(div().font_family("monospace").text_xs().child(h.clone())), + ); + } + col.into_any_element() + } + None => div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Reading attestation state…") + .into_any_element(), + }; + Card::new() + .title("Attestation status") + .description("Whether an org attestation key + chain is installed") + .icon(Icon::default().path("icons/building-2.svg")) + .header_right(refresh_btn) + .child(body) + }; + + let actions_card = Card::new() + .title("Manage") + .description("Provision or remove the org attestation") + .icon(Icon::default().path("icons/shield-check.svg")) + .child( + v_flex() + .gap_2() + .child(self.action_row( + "Import key + chain", + "P-256 key (PEM/DER) and certificate chain (PIN or touch)", + import_btn, + theme, + )) + .child(self.action_row( + "Remove attestation", + "Revert to the self-signed device certificate", + clear_btn, + theme, + )), + ); + + let content = v_flex().gap_6().child(status_card).child(actions_card); + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/attestation/view_model.rs b/src/ui/screens/attestation/view_model.rs new file mode 100644 index 0000000..cb465c4 --- /dev/null +++ b/src/ui/screens/attestation/view_model.rs @@ -0,0 +1,268 @@ +//! View model for the Attestation screen — org (enterprise) attestation key + +//! certificate chain provisioning. + +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::models::device::{AttStatus, DeviceEvent, DeviceRepo, FirmwareType}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::input::InputState; +use gpui_component::WindowExt; + +pub struct AttestationViewModel { + pub(super) device: Entity, + pub(super) status: Option, + pub(super) loading: bool, + _task: Option>, +} + +pub enum AttestationEvent { + Notification(String), +} + +impl EventEmitter for AttestationViewModel {} + +impl AttestationViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + if this.device.read(cx).device_changed { + this.status = None; + } + this.load(cx); + cx.notify(); + }) + .detach(); + let mut this = Self { + device, + status: None, + loading: false, + _task: None, + }; + this.load(cx); + this + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + match &repo.status { + None => AppletGate::Unsupported, + Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported, + Some(_) => AppletGate::Ready, + } + } + + fn load(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::att_status_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + if let Ok(s) = res { + this.status = Some(s); + } + cx.notify(); + }); + })); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.load(cx); + } + + fn pin_input(window: &mut Window, cx: &mut Context) -> Entity { + cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("FIDO PIN — leave blank to touch instead") + }) + } + + // ── Import (key file → chain file → PIN → run) ─────────────────────────── + + pub(super) fn open_import(&mut self, window: &mut Window, cx: &mut Context) { + let handle = window.window_handle(); + let key_recv = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select attestation P-256 private key (PEM/DER)".into()), + }); + let view = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(kp))) = key_recv.await else { + return; + }; + let Some(key_path) = kp.into_iter().next() else { + return; + }; + let Ok(key_bytes) = std::fs::read(&key_path) else { + let _ = view.update(cx, |_, cx| { + cx.emit(AttestationEvent::Notification("Could not read the key file".into())) + }); + return; + }; + // Now the chain file. + let chain_recv = cx.update_window(handle, |_, _window, cx| { + cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select certificate chain, leaf first (PEM/DER)".into()), + }) + }); + let Ok(chain_recv) = chain_recv else { return }; + let Ok(Ok(Some(cp))) = chain_recv.await else { + return; + }; + let Some(chain_path) = cp.into_iter().next() else { + return; + }; + let Ok(chain_bytes) = std::fs::read(&chain_path) else { + let _ = view.update(cx, |_, cx| { + cx.emit(AttestationEvent::Notification("Could not read the chain file".into())) + }); + return; + }; + let _ = cx.update_window(handle, |_, window, cx| { + let _ = view.update(cx, |this, cx| { + this.open_pin_dialog( + "Import Org Attestation", + "Installs the org attestation key and chain (P-256). Requires the FIDO PIN, or a touch if none is set.", + move |pin, this, window, cx| { + let status = dialog::open_status_dialog("Importing Attestation", window, cx); + let (kb, cb) = (key_bytes.clone(), chain_bytes.clone()); + this.run_unit( + move || DeviceRepo::att_import_blocking(pin, kb, cb), + "Org attestation installed.", + status, + cx, + ); + }, + window, + cx, + ); + }); + }); + })); + } + + // ── Clear ──────────────────────────────────────────────────────────────── + + pub(super) fn open_clear(&mut self, window: &mut Window, cx: &mut Context) { + self.open_pin_dialog( + "Remove Org Attestation", + "Removes the org attestation and reverts to the self-signed device certificate. Requires the FIDO PIN, or a touch if none is set.", + |pin, this, window, cx| { + let status = dialog::open_status_dialog("Removing Attestation", window, cx); + this.run_unit( + move || DeviceRepo::att_clear_blocking(pin), + "Org attestation removed.", + status, + cx, + ); + }, + window, + cx, + ); + } + + /// An optional-PIN dialog that invokes `on_submit(pin, this, window, cx)`. + fn open_pin_dialog( + &mut self, + title: &'static str, + body: &'static str, + on_submit: impl Fn(Option, &mut Self, &mut Window, &mut Context) + 'static, + window: &mut Window, + cx: &mut Context, + ) { + let pin = Self::pin_input(window, cx); + let view = cx.entity().downgrade(); + let on_submit = std::rc::Rc::new(on_submit); + let submit = { + let pin = pin.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + let p = (!p.is_empty()).then_some(p); + window.close_dialog(cx); + let on_submit = on_submit.clone(); + let _ = view.update(cx, |this, cx| on_submit(p, this, window, cx)); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let pin = pin.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(title) + .child(body) + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child("FIDO PIN") + .child(gpui_component::input::Input::new(&pin)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("go") + .primary() + .label("Run") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + fn run_unit( + &mut self, + op: impl FnOnce() -> Result<(), String> + Send + 'static, + ok_msg: &'static str, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Working… touch the device (BOOTSEL).", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op() }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx)); + this.load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } +} diff --git a/src/ui/screens/audit/mod.rs b/src/ui/screens/audit/mod.rs new file mode 100644 index 0000000..a40bb67 --- /dev/null +++ b/src/ui/screens/audit/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::AuditViewModel; diff --git a/src/ui/screens/audit/view.rs b/src/ui/screens/audit/view.rs new file mode 100644 index 0000000..4fee01d --- /dev/null +++ b/src/ui/screens/audit/view.rs @@ -0,0 +1,265 @@ +//! Audit screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::models::device::audit; +use crate::ui::screens::audit::view_model::AuditViewModel; +use gpui::*; +use gpui_component::button::Button; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) + .into_any_element() +} + +fn mono(theme: &Theme, s: String) -> AnyElement { + div() + .font_family("monospace") + .text_xs() + .text_color(theme.muted_foreground) + .child(s) + .into_any_element() +} + +fn short_hex(bytes: &[u8; 32]) -> String { + let h = hex::encode(bytes); + format!("{}…{}", &h[..8], &h[h.len() - 8..]) +} + +impl AuditViewModel { + fn entry_row(entry: &audit::AuditEntry, theme: &Theme) -> AnyElement { + h_flex() + .gap_3() + .py_1() + .text_sm() + .child(div().w(px(56.)).text_color(theme.muted_foreground).child(entry.seq.to_string())) + .child(div().w(px(72.)).text_color(theme.muted_foreground).child(format!("{:.1}s", entry.uptime_s()))) + .child(div().w(px(160.)).font_medium().child(entry.event_label())) + .child(div().w(px(40.)).text_color(theme.muted_foreground).child(entry.aux.to_string())) + .child( + div() + .flex_1() + .font_family("monospace") + .text_xs() + .text_color(theme.muted_foreground) + .child(entry.detail_hex()), + ) + .into_any_element() + } + + fn journal_body(&self, theme: &Theme) -> AnyElement { + let Some(j) = &self.journal else { + return div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Read the journal to view the security-event log.") + .into_any_element(); + }; + + let header = h_flex() + .gap_3() + .pb_1() + .text_xs() + .font_semibold() + .text_color(theme.muted_foreground) + .child(div().w(px(56.)).child("seq")) + .child(div().w(px(72.)).child("uptime")) + .child(div().w(px(160.)).child("event")) + .child(div().w(px(40.)).child("aux")) + .child(div().flex_1().child("detail")); + + let mut rows = vec![header.into_any_element()]; + for e in &j.entries { + rows.push(Self::entry_row(e, theme)); + } + if j.entries.is_empty() { + rows.push( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("No live entries in the window.") + .into_any_element(), + ); + } + + v_flex() + .gap_2() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child(format!( + "Window [{}, {}) — {} entries, {} folded into the epoch", + j.start, + j.seq_next, + j.entries.len(), + j.start, + ))) + .child(mono(theme, format!("epoch {}", short_hex(&j.epoch)))) + .child(mono(theme, format!("head {} (chain OK)", short_hex(&j.head)))), + ) + .child(div().h(px(1.)).bg(theme.border)) + .child(v_flex().gap_0p5().children(rows)) + .into_any_element() + } + + fn verify_body(&self, theme: &Theme) -> AnyElement { + let Some(v) = &self.verification else { + return div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Verify a signed checkpoint to prove the journal is authentic and the device genuine.") + .into_any_element(); + }; + + let (label, color) = if v.authentic() { + ("Authentic ✓", theme.green) + } else { + ("Not trusted ✗", theme.danger) + }; + + let kv = |k: &str, val: String| { + v_flex() + .gap_0p5() + .child(div().text_xs().text_color(theme.muted_foreground).child(k.to_string())) + .child(div().font_family("monospace").text_xs().child(val)) + }; + + let expected_line = match v.expected_match { + Some(true) => Some(("Pinned key", "matches ✓".to_string())), + Some(false) => Some(("Pinned key", "MISMATCH ✗".to_string())), + None => None, + }; + + let mut col = v_flex() + .gap_3() + .child( + h_flex() + .gap_2() + .items_center() + .child(div().w(px(10.)).h(px(10.)).rounded_full().bg(color)) + .child(div().font_semibold().text_color(color).child(label)), + ) + .child(div().text_sm().child(format!( + "Signature {} · chain head {} · checkpoint over seq {}", + if v.signature_ok { "OK" } else { "INVALID" }, + if v.head_matches { "bound" } else { "MISMATCH" }, + v.seq_signed, + ))) + .child(kv("Attestation key", v.pubkey_hex.clone())) + .child(kv( + "Fingerprint (pin later with Expected key)", + v.fingerprint.clone(), + )); + if let Some((k, val)) = expected_line { + col = col.child(kv(k, val)); + } + col.into_any_element() + } +} + +impl Render for AuditViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Audit"; + const SUBTITLE: &str = "Tamper-evident security journal."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let read_btn = Button::new("audit-read") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .label("Read journal") + .outline() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_read(window, cx))); + let verify_btn = Button::new("audit-verify") + .label("Verify") + .outline() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_verify(window, cx))); + let toggle_btn = match self.enabled { + Some(true) => Some( + Button::new("audit-disable") + .label("Disable") + .outline() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_toggle(false, window, cx))), + ), + Some(false) => Some( + Button::new("audit-enable") + .label("Enable") + .outline() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_toggle(true, window, cx))), + ), + None => None, + }; + + let theme = cx.theme(); + let journal_body = self.journal_body(theme); + let verify_body = self.verify_body(theme); + + let (dot, status_text) = match self.enabled { + Some(true) => (theme.green, "On — recording security events to the key's flash."), + Some(false) => ( + theme.muted_foreground, + "Off — journalling is opt-in; nothing is being recorded.", + ), + None => (theme.muted_foreground, "Reading status…"), + }; + let status_body = h_flex() + .gap_2() + .items_center() + .child(div().w(px(10.)).h(px(10.)).rounded_full().bg(dot)) + .child(div().text_sm().child(status_text.to_string())); + let status_card = { + let mut c = Card::new() + .title("Journalling") + .description("Turn the tamper-evident journal on or off (PIN + touch)") + .icon(Icon::default().path("icons/book-open.svg")); + if let Some(btn) = toggle_btn { + c = c.header_right(btn); + } + c.child(status_body) + }; + + let journal_card = Card::new() + .title("Audit journal") + .description("Hash-chained security events (boots, FIDO ops, PIN, config)") + .icon(Icon::default().path("icons/scroll-text.svg")) + .header_right(read_btn) + .child(journal_body); + + let verify_card = Card::new() + .title("Checkpoint verification") + .description("DEVK-signed proof of authenticity and device identity") + .icon(Icon::default().path("icons/shield-check.svg")) + .header_right(verify_btn) + .child(verify_body); + + let content = v_flex() + .gap_6() + .child(status_card) + .child(journal_card) + .child(verify_card); + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/audit/view_model.rs b/src/ui/screens/audit/view_model.rs new file mode 100644 index 0000000..c4cfa60 --- /dev/null +++ b/src/ui/screens/audit/view_model.rs @@ -0,0 +1,355 @@ +//! View model for the Audit screen — export and verify the device's +//! tamper-evident security journal. + +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::models::device::{audit, DeviceEvent, DeviceRepo, FirmwareType}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::input::InputState; +use gpui_component::WindowExt; + +pub struct AuditViewModel { + pub(super) device: Entity, + pub(super) journal: Option, + pub(super) verification: Option, + /// Whether journalling is currently on. `None` until the status is read (the + /// query is ungated, so it loads automatically). Journalling is opt-in. + pub(super) enabled: Option, + pub(super) loading: bool, + _task: Option>, +} + +impl AuditViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + if this.device.read(cx).device_changed { + this.journal = None; + this.verification = None; + this.enabled = None; + } + this.refresh_status(cx); + cx.notify(); + }) + .detach(); + let mut this = Self { + device, + journal: None, + verification: None, + enabled: None, + loading: false, + _task: None, + }; + this.refresh_status(cx); + this + } + + /// Load whether journalling is on (ungated — no PIN, no touch). + pub(super) fn refresh_status(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::audit_status_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.enabled = res.ok(); + cx.notify(); + }); + })); + } + + // ── Enable / disable journalling (PIN + touch) ────────────────────────── + + pub(super) fn open_toggle(&mut self, enable: bool, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + let p = (!p.is_empty()).then_some(p); + window.close_dialog(cx); + let status = dialog::open_status_dialog( + if enable { "Enabling Journalling" } else { "Disabling Journalling" }, + window, + cx, + ); + let _ = view.update(cx, |this, cx| this.run_toggle(enable, p, status, cx)); + }) + }; + let (title, body) = if enable { + ( + "Enable Audit Journalling", + "Turns the tamper-evident journal ON — security events are then recorded to the key's flash. Requires the FIDO PIN (or a touch if none is set) plus a touch to confirm.", + ) + } else { + ( + "Disable Audit Journalling", + "Turns the journal OFF — no further events are recorded. Requires the FIDO PIN (or a touch if none is set) plus a touch to confirm.", + ) + }; + Self::open_gate_dialog(title, body, pin, None, submit, window, cx); + } + + fn run_toggle( + &mut self, + enable: bool, + pin: Option, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Touch the device (BOOTSEL) to confirm.", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::audit_set_enabled_blocking(enable, pin) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(on) => { + this.enabled = Some(on); + let _ = status.update(cx, |d, cx| { + d.set_success( + format!("Journalling {}.", if on { "enabled" } else { "disabled" }), + cx, + ) + }); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + match &repo.status { + None => AppletGate::Unsupported, + Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported, + Some(_) => AppletGate::Ready, + } + } + + /// Masked, optional PIN input (blank = authorise by touch). + fn pin_input(window: &mut Window, cx: &mut Context) -> Entity { + cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("FIDO PIN — leave blank to touch instead") + }) + } + + // ── Read journal ──────────────────────────────────────────────────────── + + pub(super) fn open_read(&mut self, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + let p = (!p.is_empty()).then_some(p); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Reading Journal", window, cx); + let _ = view.update(cx, |this, cx| this.run_read(p, status, cx)); + }) + }; + Self::open_gate_dialog( + "Read Audit Journal", + "Exports the security journal. Requires the FIDO PIN, or a touch if no PIN is set.", + pin, + None, + submit, + window, + cx, + ); + } + + fn run_read( + &mut self, + pin: Option, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Reading… touch the device (BOOTSEL) if it blinks.", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::audit_log_blocking(pin) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(journal) => { + let n = journal.entries.len(); + this.journal = Some(journal); + this.verification = None; + let _ = status.update(cx, |d, cx| { + d.set_success(format!("Journal read — {n} entries."), cx) + }); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + // ── Verify checkpoint ─────────────────────────────────────────────────── + + pub(super) fn open_verify(&mut self, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let expect = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Expected key: 16-hex fingerprint or full pubkey (optional)") + }); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + let expect = expect.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + let p = (!p.is_empty()).then_some(p); + let e = expect.read(cx).text().to_string(); + let e = (!e.trim().is_empty()).then_some(e); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Verifying Checkpoint", window, cx); + let _ = view.update(cx, |this, cx| this.run_verify(p, e, status, cx)); + }) + }; + Self::open_gate_dialog( + "Verify Audit Checkpoint", + "Exports the journal and checks a fresh DEVK-signed checkpoint over it — proving the log is authentic and the device genuine.", + pin, + Some(("Expected key (optional)", expect)), + submit, + window, + cx, + ); + } + + fn run_verify( + &mut self, + pin: Option, + expect: Option, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Signing checkpoint… touch the device (BOOTSEL).", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::audit_verify_blocking(pin, expect) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(v) => { + let msg = if v.authentic() { + "Journal authentic — signature and chain verified.".to_string() + } else if !v.signature_ok { + "SIGNATURE INVALID — do not trust this journal.".to_string() + } else if !v.head_matches { + "Head mismatch — the journal changed mid-read (possible tamper)." + .to_string() + } else { + "Attestation key MISMATCH — not the enrolled device.".to_string() + }; + this.journal = Some(v.journal.clone()); + this.verification = Some(v); + let _ = status.update(cx, |d, cx| d.set_success(msg, cx)); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + /// A dialog with an optional-PIN field and an optional second field. + fn open_gate_dialog( + title: &'static str, + body: &'static str, + pin: Entity, + extra: Option<(&'static str, Entity)>, + submit: std::rc::Rc, + window: &mut Window, + cx: &mut Context, + ) { + window.open_dialog(cx, move |dialog, _w, _| { + let pin = pin.clone(); + let extra = extra.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let mut fields = gpui_component::v_flex() + .gap_3() + .pb_2() + .child("FIDO PIN") + .child(gpui_component::input::Input::new(&pin)); + if let Some((label, input)) = &extra { + fields = fields + .child(label.to_string()) + .child(gpui_component::input::Input::new(input)); + } + dialog + .title(title) + .child(body) + .child(fields) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("run") + .primary() + .label("Run") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } +} diff --git a/src/ui/screens/backup/mod.rs b/src/ui/screens/backup/mod.rs new file mode 100644 index 0000000..385e0e2 --- /dev/null +++ b/src/ui/screens/backup/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::BackupViewModel; diff --git a/src/ui/screens/backup/view.rs b/src/ui/screens/backup/view.rs new file mode 100644 index 0000000..8ae3e6b --- /dev/null +++ b/src/ui/screens/backup/view.rs @@ -0,0 +1,213 @@ +//! Backup screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::screens::backup::view_model::BackupViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) + .into_any_element() +} + +impl BackupViewModel { + fn action_row( + &self, + title: &'static str, + subtitle: &'static str, + btn: Button, + theme: &Theme, + ) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + ) + .child(btn) + } + + fn exported_card(&self, phrase: &str, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let copy = { + let p = phrase.to_string(); + Button::new("bk-copy") + .label("Copy") + .ghost() + .on_click(cx.listener(move |_, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(p.clone())); + })) + }; + let clear = Button::new("bk-clear") + .label("Clear from screen") + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.clear_exported(cx))); + + Card::new() + .title("Recovery phrase") + .description("Shown once — write it down now, then seal the window") + .icon(Icon::default().path("icons/key-round.svg")) + .header_right(h_flex().gap_2().child(copy).child(clear)) + .child( + v_flex() + .gap_3() + .child( + div() + .p_3() + .rounded_md() + .bg(rgb(0x18181b)) + .text_color(rgb(0xf59e0b)) + .text_sm() + .child("Anyone with this phrase can clone your FIDO identity. Store it offline; never paste it into a website."), + ) + .child( + div() + .p_4() + .rounded_lg() + .border_1() + .border_color(theme.border) + .font_family("monospace") + .text_sm() + .child(phrase.to_string()), + ), + ) + .into_any_element() + } +} + +impl Render for BackupViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Backup"; + const SUBTITLE: &str = "Wallet-style FIDO seed backup and restore."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let status = self.status; + let exported = self.exported.clone(); + + let exported_card = exported.map(|p| self.exported_card(&p, cx)); + + let refresh_btn = Button::new("bk-refresh") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + let export_btn = Button::new("bk-export") + .label("Export seed") + .danger() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_export(window, cx))); + let seal_btn = Button::new("bk-seal") + .label("Seal window") + .outline() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_finalize(window, cx))); + let restore_btn = Button::new("bk-restore") + .label("Restore seed") + .danger() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_restore(window, cx))); + + let theme = cx.theme(); + + let status_card = { + let body = match status { + Some(s) => { + let yn = |b: bool| if b { "yes" } else { "no" }; + let export_state = if s.sealed { + "sealed (export refused until a factory reset)" + } else if s.has_seed { + "open — seed can be exported once" + } else { + "no seed present" + }; + v_flex() + .gap_2() + .child(div().text_sm().child(format!("Seed present: {}", yn(s.has_seed)))) + .child(div().text_sm().child(format!("Export window: {export_state}"))) + .into_any_element() + } + None => div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Reading backup state…") + .into_any_element(), + }; + Card::new() + .title("Backup status") + .description("Whether a seed is present and the export window is open") + .icon(Icon::default().path("icons/cpu.svg")) + .header_right(refresh_btn) + .child(body) + }; + + let export_card = Card::new() + .title("Export") + .description("Reveal the seed as a 24-word phrase, then seal the window") + .icon(Icon::default().path("icons/lock-open.svg")) + .child( + v_flex() + .gap_2() + .child(self.action_row( + "Export seed", + "Show the recovery phrase (offline; PIN or touch)", + export_btn, + theme, + )) + .child(self.action_row( + "Seal export window", + "Refuse further exports until a factory reset", + seal_btn, + theme, + )), + ); + + let restore_card = Card::new() + .title("Restore") + .description("Install a seed from a 24-word phrase") + .icon(Icon::default().path("icons/lock.svg")) + .child(self.action_row( + "Restore seed", + "Replace the FIDO identity from a recovery phrase", + restore_btn, + theme, + )); + + let content = v_flex() + .gap_6() + .child(status_card) + .children(exported_card) + .child(export_card) + .child(restore_card); + + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/backup/view_model.rs b/src/ui/screens/backup/view_model.rs new file mode 100644 index 0000000..2550465 --- /dev/null +++ b/src/ui/screens/backup/view_model.rs @@ -0,0 +1,310 @@ +//! View model for the Backup screen — wallet-style FIDO seed export/restore. + +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::models::device::{backup, DeviceEvent, DeviceRepo, FirmwareType}; +use gpui::*; +use gpui_component::button::{ButtonVariant, ButtonVariants}; +use gpui_component::input::InputState; +use gpui_component::WindowExt; + +pub struct BackupViewModel { + pub(super) device: Entity, + pub(super) status: Option, + /// The last exported mnemonic, held for on-screen display until cleared. + pub(super) exported: Option, + pub(super) loading: bool, + _task: Option>, +} + +impl BackupViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + if this.device.read(cx).device_changed { + this.status = None; + this.exported = None; + } + this.load(cx); + cx.notify(); + }) + .detach(); + let mut this = Self { + device, + status: None, + exported: None, + loading: false, + _task: None, + }; + this.load(cx); + this + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + match &repo.status { + None => AppletGate::Unsupported, + Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported, + Some(_) => AppletGate::Ready, + } + } + + fn load(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::backup_status_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + if let Ok(s) = res { + this.status = Some(s); + } + cx.notify(); + }); + })); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.load(cx); + } + + pub(super) fn clear_exported(&mut self, cx: &mut Context) { + self.exported = None; + cx.notify(); + } + + fn pin_input(window: &mut Window, cx: &mut Context) -> Entity { + cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("FIDO PIN — leave blank to touch instead") + }) + } + + // ── Export ────────────────────────────────────────────────────────────── + + pub(super) fn open_export(&mut self, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + let p = (!p.is_empty()).then_some(p); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Exporting Seed", window, cx); + let _ = view.update(cx, |this, cx| this.run_export(p, status, cx)); + }) + }; + Self::gated_dialog( + "Export FIDO Seed", + "Reveals the 32-byte master seed as a 24-word phrase — anyone with it can clone this FIDO identity. Do it offline, write it down, then seal the window. Requires the FIDO PIN, or a touch if none is set.", + pin, + None, + ("Export", ButtonVariant::Danger), + submit, + window, + cx, + ); + } + + fn run_export( + &mut self, + pin: Option, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Exporting… touch the device (BOOTSEL).", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::backup_export_blocking(pin) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(mnemonic) => { + this.exported = Some(mnemonic); + this.load(cx); + let _ = status.update(cx, |d, cx| { + d.set_success( + "Seed exported — write down the phrase shown below, then seal the window.".into(), + cx, + ) + }); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + // ── Finalize (seal) ───────────────────────────────────────────────────── + + pub(super) fn open_finalize(&mut self, window: &mut Window, cx: &mut Context) { + let view = cx.entity().downgrade(); + dialog::open_confirm( + "Seal Export Window", + "Permanently refuses further seed exports until a FIDO factory reset. Only do this after you have safely recorded the phrase. Touch the device to confirm.".to_string(), + "Seal", + ButtonVariant::Primary, + window, + cx, + move |_dh, window, cx| { + window.close_dialog(cx); + let status = dialog::open_status_dialog("Sealing Window", window, cx); + let _ = view.update(cx, |this, cx| { + this.run_unit(DeviceRepo::backup_finalize_blocking, "Export window sealed.", status, cx); + }); + }, + ); + } + + // ── Restore ───────────────────────────────────────────────────────────── + + pub(super) fn open_restore(&mut self, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let phrase = cx.new(|cx| { + InputState::new(window, cx).placeholder("24 words separated by spaces") + }); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + let phrase = phrase.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let m = phrase.read(cx).text().to_string(); + if m.trim().is_empty() { + return; + } + let p = pin.read(cx).text().to_string(); + let p = (!p.is_empty()).then_some(p); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Restoring Seed", window, cx); + let _ = view.update(cx, |this, cx| { + this.run_unit( + move || DeviceRepo::backup_restore_blocking(p, m), + "Seed restored — the FIDO identity now matches the backup.", + status, + cx, + ); + }); + }) + }; + Self::gated_dialog( + "Restore FIDO Seed", + "Installs a seed from a 24-word phrase, replacing the device's FIDO identity. Requires the FIDO PIN, or a touch if none is set.", + pin, + Some(("Recovery phrase", phrase)), + ("Restore", ButtonVariant::Danger), + submit, + window, + cx, + ); + } + + /// Run a blocking op returning `()`, reporting on `status` and reloading. + fn run_unit( + &mut self, + op: impl FnOnce() -> Result<(), String> + Send + 'static, + ok_msg: &'static str, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Working… touch the device (BOOTSEL).", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op() }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx)); + this.load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + /// A dialog with a warning body, an optional-PIN field, an optional second + /// text field, and a coloured submit button. + #[allow(clippy::too_many_arguments)] + fn gated_dialog( + title: &'static str, + body: &'static str, + pin: Entity, + extra: Option<(&'static str, Entity)>, + action: (&'static str, ButtonVariant), + submit: std::rc::Rc, + window: &mut Window, + cx: &mut Context, + ) { + window.open_dialog(cx, move |dialog, _w, _| { + let pin = pin.clone(); + let extra = extra.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let (action_label, action_variant) = action; + let mut fields = gpui_component::v_flex().gap_3().pb_2(); + if let Some((label, input)) = &extra { + fields = fields + .child(label.to_string()) + .child(gpui_component::input::Input::new(input)); + } + fields = fields + .child("FIDO PIN") + .child(gpui_component::input::Input::new(&pin)); + dialog + .title(title) + .child(body) + .child(fields) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("go") + .with_variant(action_variant) + .label(action_label) + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } +} diff --git a/src/ui/screens/config/view.rs b/src/ui/screens/config/view.rs index dfa0403..ec9c7a3 100644 --- a/src/ui/screens/config/view.rs +++ b/src/ui/screens/config/view.rs @@ -7,6 +7,12 @@ use crate::ui::screens::config::view_model::ConfigViewModel; use gpui::*; use gpui_component::{button::*, input::*, select::*, slider::*, switch::*, *}; +/// Per-status LED brightness is a full u8 on the device (0-255, 0 = off). The +/// +/- steppers move in coarse steps (15 divides 255 evenly) so the whole range +/// stays reachable without hundreds of clicks. +const LED_BRIGHTNESS_MAX: u8 = 255; +const LED_BRIGHTNESS_STEP: u8 = 15; + impl ConfigViewModel { fn render_identity_card( &self, @@ -48,11 +54,24 @@ impl ConfigViewModel { ) .child(div().h_px().bg(theme.border)) .child( - v_flex().gap_2().child("Product Name").child( - Input::new(&self.product_name_input) - .bg(rgb(0x222225)) - .disabled(is_fido), - ), + div() + .grid() + .grid_cols(2) + .gap_4() + .child( + v_flex().gap_2().child("Product Name").child( + Input::new(&self.product_name_input) + .bg(rgb(0x222225)) + .disabled(is_fido), + ), + ) + .child( + v_flex().gap_2().child("Manufacturer").child( + Input::new(&self.manufacturer_input) + .bg(rgb(0x222225)) + .disabled(is_fido), + ), + ), ); Card::new() @@ -66,101 +85,118 @@ impl ConfigViewModel { &mut self, cx: &mut Context, is_fido: bool, + is_rskey: bool, hardware_config_disabled: bool, ) -> impl IntoElement { - let dim_listener = cx.listener(|this, checked, _, cx| { - this.led_dimmable = *checked; - cx.notify(); - }); - - let steady_listener = cx.listener(|this, checked, _, cx| { - this.led_steady = *checked; - cx.notify(); - }); - - let theme = cx.theme(); - - let brightness = self.led_brightness_slider.read(cx).value().start() as i32; - - let content = v_flex() - .gap_4() - .child( - h_flex() - .gap_4() - .flex_wrap() - .child( - v_flex().gap_2().flex_1().child("LED GPIO Pin").child( - Input::new(&self.led_gpio_input) - .bg(rgb(0x222225)) - .disabled(hardware_config_disabled), - ), - ) - .child( - v_flex().gap_2().flex_1().child("LED Driver").child( - Select::new(&self.led_driver_select) - .w_full() - .bg(rgb(0x222225)) - .disabled(is_fido), - ), + // GPIO pin + driver are the LED hardware topology — always shown. + let mut content = v_flex().gap_4().child( + h_flex() + .gap_4() + .flex_wrap() + .child( + v_flex().gap_2().flex_1().child("LED GPIO Pin").child( + Input::new(&self.led_gpio_input) + .bg(rgb(0x222225)) + .disabled(hardware_config_disabled), ), - ) - .child(div().h_px().bg(theme.border)) - .child( - v_flex().gap_2().child("Brightness (0-15)").child( + ) + .child( + v_flex().gap_2().flex_1().child("LED Driver").child( + Select::new(&self.led_driver_select) + .w_full() + .bg(rgb(0x222225)) + .disabled(is_fido), + ), + ), + ); + + // Colour order is an RS-Key extension (phy tag 0x0D); pico-fido ignores + // it, so only surface it for RS-Key. Fixes red/green swap on GRB panels. + if is_rskey { + content = content.child( + v_flex().gap_2().child("LED Colour Order").child( + Select::new(&self.led_order_select) + .w_full() + .bg(rgb(0x222225)) + .disabled(hardware_config_disabled), + ), + ); + } + + // Global brightness / dimmable / steady live in the phy record. On RS-Key + // the per-status EF_LED_CONF (Status LED Colors card) overrides them at + // boot, so showing them here too would be duplicate, dead controls. + if !is_rskey { + let dim_listener = cx.listener(|this, checked, _, cx| { + this.led_dimmable = *checked; + cx.notify(); + }); + let steady_listener = cx.listener(|this, checked, _, cx| { + this.led_steady = *checked; + cx.notify(); + }); + let theme = cx.theme(); + let brightness = self.led_brightness_slider.read(cx).value().start() as i32; + + content = content + .child(div().h_px().bg(theme.border)) + .child( + v_flex().gap_2().child("Brightness (0-15)").child( + h_flex() + .items_center() + .gap_4() + .child( + Slider::new(&self.led_brightness_slider) + .flex_1() + .disabled(hardware_config_disabled), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!("Level {}", brightness)), + ), + ), + ) + .child( h_flex() .items_center() - .gap_4() + .justify_between() .child( - Slider::new(&self.led_brightness_slider) - .flex_1() - .disabled(hardware_config_disabled), + v_flex().gap_0p5().child("LED Dimmable").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Allow brightness adjustment"), + ), ) .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(format!("Level {}", brightness)), + Switch::new("led-dimmable") + .checked(self.led_dimmable) + .disabled(hardware_config_disabled) + .on_click(dim_listener), ), - ), - ) - .child( - h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("LED Dimmable").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Allow brightness adjustment"), + ) + .child( + h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("LED Steady Mode").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Keep LED on constantly"), + ), + ) + .child( + Switch::new("led-steady") + .checked(self.led_steady) + .disabled(hardware_config_disabled) + .on_click(steady_listener), ), - ) - .child( - Switch::new("led-dimmable") - .checked(self.led_dimmable) - .disabled(hardware_config_disabled) - .on_click(dim_listener), - ), - ) - .child( - h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("LED Steady Mode").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Keep LED on constantly"), - ), - ) - .child( - Switch::new("led-steady") - .checked(self.led_steady) - .disabled(hardware_config_disabled) - .on_click(steady_listener), - ), - ); + ); + } Card::new() .title("LED Settings") @@ -267,18 +303,15 @@ impl ConfigViewModel { }); let dec_bright_listener = cx.listener(move |this, _, _, cx| { - let mut b = this.led_status_brightness[i]; - b = b.saturating_sub(1); - this.led_status_brightness[i] = b; + let b = this.led_status_brightness[i]; + this.led_status_brightness[i] = b.saturating_sub(LED_BRIGHTNESS_STEP); cx.notify(); }); let inc_bright_listener = cx.listener(move |this, _, _, cx| { - let mut b = this.led_status_brightness[i]; - if b < 15 { - b += 1; - } - this.led_status_brightness[i] = b; + let b = this.led_status_brightness[i]; + this.led_status_brightness[i] = + b.saturating_add(LED_BRIGHTNESS_STEP).min(LED_BRIGHTNESS_MAX); cx.notify(); }); @@ -339,32 +372,13 @@ impl ConfigViewModel { .active(rgb(0x3f3f46).into()) .border(theme.border), ) - .disabled(is_fido || brightness_val >= 15) + .disabled(is_fido || brightness_val >= LED_BRIGHTNESS_MAX) .on_click(inc_bright_listener), ), ), ); } - rows = rows.child(div().h_px().bg(theme.border)); - rows = rows.child( - h_flex().justify_end().child( - Button::new("apply-rskey-leds") - .child("Save LED Status") - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0xe3e3e6).into()) - .hover(rgb(0xcfcfd1).into()) - .active(rgb(0xe3e3e6).into()) - .foreground(rgb(0x4b4b4e).into()), - ) - .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") @@ -425,25 +439,6 @@ impl ConfigViewModel { ); } - rows = rows.child(div().h_px().bg(theme.border)); - rows = rows.child( - h_flex().justify_end().child( - Button::new("apply-rskey-apps") - .child("Save USB Applications") - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0xe3e3e6).into()) - .hover(rgb(0xcfcfd1).into()) - .active(rgb(0xe3e3e6).into()) - .foreground(rgb(0x4b4b4e).into()), - ) - .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") @@ -457,19 +452,37 @@ impl ConfigViewModel { is_fido: bool, ) -> impl IntoElement { let theme = cx.theme(); - let mut rows = v_flex().gap_4(); + // Only the interfaces the firmware actually instantiates (USB_ITF_SUPPORTED + // = CCID | HID | KB). WCID (WebUSB) and LWIP are pico-fido concepts RS-Key + // never builds, so toggling them would be a no-op — don't offer them. + let mut rows = v_flex().gap_4().child( + div() + .text_sm() + .text_color(rgb(0xf59e0b)) + .child("Advanced. HID off disables all FIDO2/U2F; CCID off disables every smart-card app (and the rescue applet). The firmware always keeps one of them, so you can't lock yourself out here."), + ); let interfaces = [ - ("CCID (Smart Card)", 0x01u8), - ("WCID (WebUSB)", 0x02u8), - ("HID (FIDO)", 0x04u8), - ("KB (Keyboard)", 0x08u8), - ("LWIP", 0x10u8), + ( + "CCID (Smart Card)", + 0x01u8, + "Required for the rescue applet and all smart-card apps", + ), + ( + "HID (FIDO)", + 0x04u8, + "FIDO/CTAP transport — off disables all FIDO2 and U2F", + ), + ( + "KB (Keyboard)", + 0x08u8, + "OTP keyboard — Yubico OTP and static-password typing", + ), ]; let current_mask = self.enabled_usb_itf.unwrap_or(0x1F); - for (name, bit) in interfaces { + for (name, bit, desc) in interfaces { let is_enabled = (current_mask & bit) != 0; let is_ccid = bit == 0x01; @@ -498,11 +511,7 @@ impl ConfigViewModel { div() .text_sm() .text_color(theme.muted_foreground) - .child(if is_ccid { - "Required for Rescue Applet" - } else { - "USB Endpoint" - }), + .child(desc), ), ) .child( @@ -566,7 +575,7 @@ impl Render for ConfigViewModel { let is_fido_no_rskey = is_fido && !is_rskey; let led_card = self - .render_led_card(cx, is_fido_no_rskey, hardware_config_disabled) + .render_led_card(cx, is_fido_no_rskey, is_rskey, hardware_config_disabled) .into_any_element(); let options_card = self .render_options_card(cx, hardware_config_disabled) @@ -579,22 +588,27 @@ impl Render for ConfigViewModel { .render_touch_card(cx.theme(), is_fido_no_rskey) .into_any_element(); - let mut inner = v_flex() - .gap_6() - .child(identity_card) - .child(led_card) - .child(touch_card) - .child(options_card); + let mut inner = v_flex().gap_6().child(identity_card); + // RS-Key: put the functional config (which apps + transports are on) + // right after Identity, before appearance/misc, so the panel reads + // top-down by importance rather than burying it under the LED cards. + // No curves card: the firmware ignores the phy ENABLED_CURVES tag + // (curve support is compile-time), so exposing it would only mislead. 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_rskey_led_card(cx, false)) .child(self.render_rskey_apps_card(cx, false)) .child(self.render_rskey_usb_itf_card(cx, false)); } + inner = inner.child(led_card); + + if is_rskey { + inner = inner.child(self.render_rskey_led_card(cx, false)); + } + + inner = inner.child(touch_card).child(options_card); + inner = inner.child( h_flex().justify_end().pt_4().child( Button::new("apply-changes") diff --git a/src/ui/screens/config/view_model.rs b/src/ui/screens/config/view_model.rs index ab3175e..f81249a 100644 --- a/src/ui/screens/config/view_model.rs +++ b/src/ui/screens/config/view_model.rs @@ -12,12 +12,19 @@ use gpui::*; use gpui_component::input::InputState; use gpui_component::select::{SelectItem, SelectState}; use gpui_component::slider::SliderState; +use std::time::Duration; /// 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; +/// After a PHY config write, RS-Key firmware warm-reboots and re-enumerates on +/// its own, so the confirmation read must wait for the device to re-appear +/// instead of racing the disconnect (which would leave the form stale). +const POST_WRITE_READ_ATTEMPTS: u32 = 20; +const POST_WRITE_READ_INTERVAL: Duration = Duration::from_millis(250); + /// Known USB vendor/product identity presets for various security keys. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UsbIdentityPreset { @@ -163,6 +170,34 @@ impl LedDriverType { } } +/// LED colour ordering (RS-Key phy tag 0x0D). The firmware treats any non-zero +/// value as "swap red/green" for GRB panels; 0 is straight RGB. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedColorOrder { + Rgb, + Grb, +} + +impl LedColorOrder { + pub fn label(&self) -> SharedString { + match self { + Self::Rgb => "RGB".into(), + Self::Grb => "GRB (swap red/green)".into(), + } + } + + pub fn value(&self) -> u8 { + match self { + Self::Rgb => 0, + Self::Grb => 1, + } + } + + pub fn all() -> &'static [Self] { + &[Self::Rgb, Self::Grb] + } +} + #[derive(Clone, PartialEq)] pub(super) struct VendorSelectOption { preset: UsbIdentityPreset, @@ -183,12 +218,13 @@ impl SelectItem for VendorSelectOption { #[derive(Clone, PartialEq)] pub(super) struct DriverSelectOption { - driver_type: LedDriverType, + /// `None` is the "Firmware default" sentinel (row 0); real drivers follow. + driver_type: Option, label: SharedString, } impl SelectItem for DriverSelectOption { - type Value = LedDriverType; + type Value = Option; fn title(&self) -> SharedString { self.label.clone() @@ -199,6 +235,24 @@ impl SelectItem for DriverSelectOption { } } +#[derive(Clone, PartialEq)] +pub(super) struct OrderSelectOption { + order: LedColorOrder, + label: SharedString, +} + +impl SelectItem for OrderSelectOption { + type Value = LedColorOrder; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &Self::Value { + &self.order + } +} + pub(super) enum StatusDialogHandle { Pin(WeakEntity), Status(WeakEntity), @@ -211,8 +265,10 @@ pub struct ConfigViewModel { pub(super) vid_input: Entity, pub(super) pid_input: Entity, pub(super) product_name_input: Entity, + pub(super) manufacturer_input: Entity, pub(super) led_gpio_input: Entity, pub(super) led_driver_select: Entity>>, + pub(super) led_order_select: Entity>>, pub(super) led_brightness_slider: Entity, pub(super) led_dimmable: bool, pub(super) led_steady: bool, @@ -255,6 +311,11 @@ impl ConfigViewModel { let device_read = device.read(cx); let config = device_read.status.as_ref().map(|s| &s.config); + let is_rskey = device_read + .status + .as_ref() + .map(|s| s.firmware_type == crate::ui::models::device::FirmwareType::RSKey) + .unwrap_or(false); let current_vid: SharedString = config .map(|c| c.vid.clone().into()) @@ -265,6 +326,11 @@ impl ConfigViewModel { let current_product_name: SharedString = config .map(|c| c.product_name.clone().into()) .unwrap_or_else(|| "My Key".into()); + // Blank when there is no phy override — the field then reads as "use the + // VID-derived default" and isn't written back on save. + let current_manufacturer: SharedString = config + .map(|c| c.manufacturer_name.clone().into()) + .unwrap_or_default(); // `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 @@ -288,7 +354,30 @@ impl ConfigViewModel { .and_then(|c| c.raw_curves_mask) .map(RescueCurves::from_bits_truncate) .unwrap_or(RescueCurves::empty()); - let current_driver_val = config.and_then(|c| c.led_driver).unwrap_or(0); + let current_led_driver = config.and_then(|c| c.led_driver); + let current_led_order = config.and_then(|c| c.led_order); + + // Effective values the device reports (CONFIG_READ key 2) — shown as + // placeholders / the driver-default label so an unset field displays the + // real value, not a bare "firmware default". Computed here into owned + // strings so `config`'s borrow of `cx` ends before the `cx.new` widgets. + let led_gpio_placeholder = config + .and_then(|c| c.effective_led_gpio) + .map(|g| format!("Firmware default (GPIO {g})")) + .unwrap_or_else(|| "Firmware default".to_string()); + let touch_placeholder = config + .and_then(|c| c.effective_touch_timeout) + .map(|t| format!("Firmware default ({t}s)")) + .unwrap_or_else(|| "Firmware default (30s)".to_string()); + let default_driver_label = config + .and_then(|c| c.effective_led_driver) + .and_then(|d| { + LedDriverType::all() + .iter() + .find(|x| x.value() == d) + .map(|x| format!("Firmware default — {}", x.label())) + }) + .unwrap_or_else(|| "Firmware default".to_string()); let mut led_status_steady = false; let mut led_status_colors = [0; 4]; @@ -319,11 +408,32 @@ impl ConfigViewModel { }) .collect(); - let drivers: Vec = LedDriverType::all() + // Row 0 is the "Firmware default" sentinel so a virgin phy (led_driver = + // None) doesn't masquerade as an explicitly-picked GPIO driver — and so + // an explicit pick of any real driver differs from that row and is written. + let mut drivers = vec![DriverSelectOption { + driver_type: None, + label: default_driver_label.into(), + }]; + drivers.extend( + LedDriverType::all() + .iter() + // RS-Key firmware only instantiates drivers 1..=3 (gpio / pimoroni / + // ws2812); ESP32 (5) is a different MCU it silently ignores, so don't + // offer it. The kept drivers are the prefix of `all()`, so the row + // indices `driver_row` / `apply_changes` compute stay aligned. + .filter(|driver| !is_rskey || driver.value() <= 3) + .map(|driver| DriverSelectOption { + driver_type: Some(*driver), + label: driver.label(), + }), + ); + + let orders: Vec = LedColorOrder::all() .iter() - .map(|driver| DriverSelectOption { - driver_type: *driver, - label: driver.label(), + .map(|order| OrderSelectOption { + order: *order, + label: order.label(), }) .collect(); @@ -348,17 +458,19 @@ impl ConfigViewModel { let pid_input = cx.new(|cx| InputState::new(window, cx).default_value(current_pid.clone())); let product_name_input = cx.new(|cx| InputState::new(window, cx).default_value(current_product_name.clone())); + let manufacturer_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("VID-derived default") + .default_value(current_manufacturer.clone()) + }); let led_gpio_input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Firmware default") + .placeholder(led_gpio_placeholder) .default_value(current_led_gpio.clone()) }); - let initial_driver_idx = LedDriverType::all() - .iter() - .position(|d| d.value() == current_driver_val) - .unwrap_or(0); + let initial_driver_idx = Self::driver_row(current_led_driver); let led_driver_select = cx.new(|cx| { SelectState::new( @@ -369,6 +481,18 @@ impl ConfigViewModel { ) }); + // Firmware collapses colour order to a boolean, so map None/0 → RGB (row 0) + // and any non-zero → GRB (row 1). + let initial_order_idx = usize::from(current_led_order.unwrap_or(0) != 0); + let led_order_select = cx.new(|cx| { + SelectState::new( + orders, + Some(gpui_component::IndexPath::default().row(initial_order_idx)), + window, + cx, + ) + }); + cx.subscribe_in( &vendor_select, window, @@ -401,7 +525,7 @@ impl ConfigViewModel { let touch_timeout_input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Firmware default (30s)") + .placeholder(touch_placeholder) .default_value(current_touch_timeout.clone()) }); @@ -411,8 +535,10 @@ impl ConfigViewModel { vid_input, pid_input, product_name_input, + manufacturer_input, led_gpio_input, led_driver_select, + led_order_select, led_brightness_slider, led_dimmable, led_steady, @@ -443,7 +569,9 @@ impl ConfigViewModel { pub(super) fn write_config_to_device( &mut self, - changes: AppConfigInput, + phy: Option, + led: Option, + apps: Option, method: DeviceMethod, pin: Option, dialog_handle: StatusDialogHandle, @@ -523,16 +651,30 @@ impl ConfigViewModel { let result = cx .background_executor() .spawn(async move { - DeviceRepo::write_config_blocking(changes, method_clone, pin) + DeviceRepo::write_all_config_blocking(method_clone, phy, led, apps, pin) }) .await; let dialog_handle = dialog; + // A PHY config write makes RS-Key firmware warm-reboot and re-enumerate + // on its own, so the device is briefly off the USB bus. Retry the + // confirmation read until it re-appears rather than racing the drop and + // silently discarding the refresh. let fresh_state = if result.is_ok() { - cx.background_executor() - .spawn(async move { DeviceRepo::read_device_state_blocking().ok() }) - .await + let mut state = None; + for _ in 0..POST_WRITE_READ_ATTEMPTS { + if let Some(s) = cx + .background_executor() + .spawn(async { DeviceRepo::read_device_state_blocking().ok() }) + .await + { + state = Some(s); + break; + } + cx.background_executor().timer(POST_WRITE_READ_INTERVAL).await; + } + state } else { None }; @@ -620,7 +762,9 @@ impl ConfigViewModel { fn open_pin_dialog( &mut self, - changes: AppConfigInput, + phy: Option, + led: Option, + apps: Option, window: &mut Window, cx: &mut Context, ) { @@ -636,7 +780,9 @@ impl ConfigViewModel { move |pin, dialog_handle, cx| { let _ = view_handle.update(cx, |this, cx| { this.write_config_to_device( - changes.clone(), + phy.clone(), + led.clone(), + apps, DeviceMethod::Fido, Some(pin), StatusDialogHandle::Pin(dialog_handle), @@ -654,6 +800,9 @@ impl ConfigViewModel { let current_vid = status.config.vid.clone(); let current_pid = status.config.pid.clone(); let current_product_name = status.config.product_name.clone(); + let current_manufacturer = status.config.manufacturer_name.clone(); + let current_led = device.led_status.clone(); + let current_apps_enabled = device.management_apps.as_ref().map(|a| a.usb_enabled); let current_led_gpio = status.config.led_gpio; let current_led_driver = status.config.led_driver; let current_led_brightness = status.config.led_brightness; @@ -663,7 +812,8 @@ impl ConfigViewModel { let current_power_cycle = status.config.power_cycle_on_reset; let current_enabled_usb_itf = status.config.enabled_usb_itf; let raw_curves_mask = status.config.raw_curves_mask; - let led_order = status.config.led_order; + let current_led_order = status.config.led_order; + let led_num = status.config.led_num; let method = status.method.clone(); let is_rskey = status.firmware_type == crate::ui::models::device::FirmwareType::RSKey; @@ -684,6 +834,11 @@ impl ConfigViewModel { has_changes = true; } + let manufacturer_name = self.manufacturer_input.read(cx).text().to_string(); + if manufacturer_name != current_manufacturer { + has_changes = true; + } + // 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(); @@ -697,28 +852,44 @@ impl ConfigViewModel { has_changes = true; } - // 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); + // LED driver: row 0 is the "Firmware default" sentinel (led_driver = None); + // real drivers follow. An unmoved select stays on the device's row, so a + // virgin phy isn't clobbered — while an explicit pick of any real driver + // (GPIO included) differs from the sentinel row and is written. + let init_driver_idx = Self::driver_row(current_led_driver); 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()) + let final_led_driver = if sel_driver_idx == 0 { + None } else { - current_led_driver + LedDriverType::all().get(sel_driver_idx - 1).map(|d| d.value()) }; if final_led_driver != current_led_driver { has_changes = true; } + // LED colour order: an untouched select preserves the device's value; + // firmware treats any non-zero value as GRB, so map by RGB(0)/GRB(1). + let init_order_idx = usize::from(current_led_order.unwrap_or(0) != 0); + let sel_order_idx = self + .led_order_select + .read(cx) + .selected_index(cx) + .map(|p| p.row) + .unwrap_or(init_order_idx); + let final_led_order = if sel_order_idx != init_order_idx { + LedColorOrder::all().get(sel_order_idx).map(|o| o.value()) + } else { + current_led_order + }; + if final_led_order != current_led_order { + 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); @@ -780,15 +951,11 @@ impl ConfigViewModel { final_enabled_usb_itf = self.enabled_usb_itf; } - if !has_changes { - log::info!("No changes detected"); - return; - } - let changes = AppConfigInput { vid: Some(vid), pid: Some(pid), product_name: Some(product_name), + manufacturer_name: Some(manufacturer_name), led_gpio: final_led_gpio, led_brightness: final_led_brightness, touch_timeout: final_touch_timeout, @@ -798,19 +965,53 @@ impl ConfigViewModel { led_steady: Some(self.led_steady), enable_secp256k1: None, raw_curves_mask: built_curves_mask, - led_order, + led_order: final_led_order, enabled_usb_itf: final_enabled_usb_itf, - led_num: None, + led_num, }; + // The Status LED Colors and USB Applications cards write their own device + // targets (EF_LED_CONF / the management mask); fold them into this one Save + // so the screen has a single Apply, not a mix of per-card buttons. + let led_changed = match ¤t_led { + Some(led) => { + led.steady != self.led_status_steady + || (0..4).any(|i| { + led.statuses[i] + != (self.led_status_colors[i], self.led_status_brightness[i]) + }) + } + None => false, + }; + let apps_changed = current_apps_enabled.is_some_and(|e| e != self.usb_apps_enabled); + + if !has_changes && !led_changed && !apps_changed { + log::info!("No changes detected"); + return; + } + + let phy = has_changes.then_some(changes); + let led = led_changed.then(|| LedStatusConfig { + steady: self.led_status_steady, + statuses: [ + (self.led_status_colors[0], self.led_status_brightness[0]), + (self.led_status_colors[1], self.led_status_brightness[1]), + (self.led_status_colors[2], self.led_status_brightness[2]), + (self.led_status_colors[3], self.led_status_brightness[3]), + ], + }); + let apps = apps_changed.then_some(self.usb_apps_enabled); + if method == DeviceMethod::Fido { if Self::status_supports_legacy_fido_config(status) || is_rskey { - self.open_pin_dialog(changes, window, cx); + self.open_pin_dialog(phy, led, apps, window, cx); } else { let handle = dialog::open_status_dialog("Configuration Requires Rescue Mode", window, cx); self.write_config_to_device( - changes, + phy, + led, + apps, method, None, StatusDialogHandle::Status(handle), @@ -820,7 +1021,9 @@ impl ConfigViewModel { } else { let handle = dialog::open_status_dialog("Applying Configuration", window, cx); self.write_config_to_device( - changes, + phy, + led, + apps, method, None, StatusDialogHandle::Status(handle), @@ -829,6 +1032,20 @@ impl ConfigViewModel { } } + /// Row of a phy `led_driver` value in the driver select. Row 0 is the + /// "Firmware default" sentinel (`None`); real drivers follow in + /// `LedDriverType::all()` order. + fn driver_row(current: Option) -> usize { + match current { + Some(v) => LedDriverType::all() + .iter() + .position(|d| d.value() == v) + .map(|i| i + 1) + .unwrap_or(0), + None => 0, + } + } + /// Build the curves bitmask from the current toggle states. fn curves_mask_from_toggles(&self) -> u32 { let mut mask = RescueCurves::empty(); @@ -906,8 +1123,6 @@ impl ConfigViewModel { .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); - if let Some(led) = &device.led_status { self.led_status_steady = led.steady; for i in 0..4 { @@ -923,6 +1138,11 @@ impl ConfigViewModel { self.enabled_usb_itf = config.and_then(|c| c.enabled_usb_itf); + // Resolve the select rows while `config` is still borrowed — all the + // `.update()` calls below need `cx` mutably, so no config read may outlive them. + let new_driver_idx = Self::driver_row(config.and_then(|c| c.led_driver)); + let new_order_idx = usize::from(config.and_then(|c| c.led_order).unwrap_or(0) != 0); + let preset = UsbIdentityPreset::from_vid_pid(&new_vid, &new_pid); self.is_custom_vendor = preset == UsbIdentityPreset::Custom; let preset_idx = UsbIdentityPreset::all() @@ -950,10 +1170,6 @@ impl ConfigViewModel { self.led_brightness_slider .update(cx, |slider, cx| slider.set_value(brightness, window, cx)); - let new_driver_idx = LedDriverType::all() - .iter() - .position(|d| d.value() == new_driver_val) - .unwrap_or(0); self.led_driver_select.update(cx, |select, cx| { select.set_selected_index( Some(gpui_component::IndexPath::default().row(new_driver_idx)), @@ -962,254 +1178,35 @@ impl ConfigViewModel { ); }); - cx.notify(); - } - - pub(super) fn apply_rskey_led_settings(&mut self, window: &mut Window, cx: &mut Context) { - let config = LedStatusConfig { - steady: self.led_status_steady, - statuses: [ - (self.led_status_colors[0], self.led_status_brightness[0]), - (self.led_status_colors[1], self.led_status_brightness[1]), - (self.led_status_colors[2], self.led_status_brightness[2]), - (self.led_status_colors[3], self.led_status_brightness[3]), - ], - }; - - let method = self - .device - .read(cx) - .status - .as_ref() - .map(|s| s.method.clone()); - - if method == Some(DeviceMethod::Fido) { - let view_handle = cx.entity().downgrade(); - dialog::open_pin_prompt( - "Authentication Required", - "Enter your device PIN to update LED configuration.", - None, - "Confirm", + self.led_order_select.update(cx, |select, cx| { + select.set_selected_index( + Some(gpui_component::IndexPath::default().row(new_order_idx)), window, cx, - move |pin, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.do_write_led_config( - config.clone(), - DeviceMethod::Fido, - Some(pin), - StatusDialogHandle::Pin(dialog_handle), - cx, - ); - }); - }, ); - } else { - let handle = dialog::open_status_dialog("Applying LED Configuration...", window, cx); - self.do_write_led_config( - config, - DeviceMethod::Rescue, - None, - StatusDialogHandle::Status(handle), - cx, - ); - } - } + }); - fn do_write_led_config( - &mut self, - config: LedStatusConfig, - method: DeviceMethod, - pin: Option, - dialog_handle: StatusDialogHandle, - cx: &mut Context, - ) { - self.loading = true; cx.notify(); - - let weak_self = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { DeviceRepo::write_led_config_blocking(method, config, pin) }) - .await; - - let fresh_state = if result.is_ok() { - cx.background_executor() - .spawn(async move { DeviceRepo::read_device_state_blocking().ok() }) - .await - } else { - None - }; - - let _ = weak_self.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(_) => { - if let Some(fs) = fresh_state { - this.device.update(cx, |repo, repo_cx| { - repo.apply_fresh_state(fs, repo_cx); - }); - } - match &dialog_handle { - StatusDialogHandle::Pin(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_success( - "LED configuration applied successfully.".to_string(), - cx, - ); - }); - } - StatusDialogHandle::Status(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_success( - "LED configuration applied successfully.".to_string(), - cx, - ); - }); - } - } - } - Err(e) => match &dialog_handle { - StatusDialogHandle::Pin(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_error(format!("Failed to apply LED config: {}", e), cx); - }); - } - StatusDialogHandle::Status(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_error(format!("Failed to apply LED config: {}", e), cx); - }); - } - }, - } - cx.notify(); - }); - })); } - pub(super) fn apply_rskey_apps_settings( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { - let mask = self.usb_apps_enabled; +} - let method = self - .device - .read(cx) - .status - .as_ref() - .map(|s| s.method.clone()); +#[cfg(test)] +mod tests { + use super::ConfigViewModel; - if method == Some(DeviceMethod::Fido) { - let view_handle = cx.entity().downgrade(); - dialog::open_pin_prompt( - "Authentication Required", - "Enter your device PIN to update USB application configuration.", - None, - "Confirm", - window, - cx, - move |pin, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.do_write_management_config( - mask, - DeviceMethod::Fido, - Some(pin), - StatusDialogHandle::Pin(dialog_handle), - cx, - ); - }); - }, - ); - } else { - let handle = dialog::open_status_dialog("Applying USB Applications...", window, cx); - self.do_write_management_config( - mask, - DeviceMethod::Rescue, - None, - StatusDialogHandle::Status(handle), - cx, - ); - } - } - - fn do_write_management_config( - &mut self, - mask: u16, - method: DeviceMethod, - pin: Option, - dialog_handle: StatusDialogHandle, - cx: &mut Context, - ) { - self.loading = true; - cx.notify(); - - let weak_self = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { - DeviceRepo::write_management_config_blocking(method, mask, pin) - }) - .await; - - let fresh_state = if result.is_ok() { - cx.background_executor() - .spawn(async move { DeviceRepo::read_device_state_blocking().ok() }) - .await - } else { - None - }; - - let _ = weak_self.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(_) => { - if let Some(fs) = fresh_state { - this.device.update(cx, |repo, repo_cx| { - repo.apply_fresh_state(fs, repo_cx); - }); - } - match &dialog_handle { - StatusDialogHandle::Pin(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_success( - "USB applications updated successfully. Please re-plug the device.".to_string(), - cx, - ); - }); - } - StatusDialogHandle::Status(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_success( - "USB applications updated successfully. Please re-plug the device.".to_string(), - cx, - ); - }); - } - } - } - Err(e) => { - match &dialog_handle { - StatusDialogHandle::Pin(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_error(format!("Failed to apply USB applications: {}", e), cx); - }); - } - StatusDialogHandle::Status(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_error(format!("Failed to apply USB applications: {}", e), cx); - }); - } - } - } - } - cx.notify(); - }); - })); + #[test] + fn driver_row_maps_none_to_sentinel_and_drivers_after() { + // A virgin phy (led_driver = None) selects the "Firmware default" sentinel + // at row 0 — so an explicit pick of any real driver differs from it and is + // written (the fix for GPIO being unreachable on a ws2812-default board). + assert_eq!(ConfigViewModel::driver_row(None), 0); + // Real drivers follow LedDriverType::all() order, offset past the sentinel. + assert_eq!(ConfigViewModel::driver_row(Some(1)), 1); // PicoGpio + assert_eq!(ConfigViewModel::driver_row(Some(2)), 2); // PimoroniRgb + assert_eq!(ConfigViewModel::driver_row(Some(3)), 3); // Ws2812Neopixel + assert_eq!(ConfigViewModel::driver_row(Some(5)), 4); // Esp32Neopixel + // An unrecognised value falls back to the sentinel, never a bogus index. + assert_eq!(ConfigViewModel::driver_row(Some(99)), 0); } } diff --git a/src/ui/screens/home/view.rs b/src/ui/screens/home/view.rs index aebadf4..928c2e4 100644 --- a/src/ui/screens/home/view.rs +++ b/src/ui/screens/home/view.rs @@ -35,9 +35,41 @@ impl HomeViewModel { ) } + /// RS-Key impersonates a YubiKey's CTAP `firmwareVersion` (e.g. 5.7.4), so + /// its real build id is the USB bcdDevice; prefer that when we have it. + fn firmware_version_label(status: &FullDeviceStatus) -> String { + if status.firmware_type == FirmwareType::RSKey + && let Some(bcd) = status.info.bcd_device + { + format!("RS-Key 0x{:04X}", bcd) + } else { + format!("v{}", status.info.firmware_version) + } + } + + /// Human-readable flash chip size. RP2350 boards are whole-MB (2/4/16 MB). + fn format_flash_size(bytes: u32) -> String { + const MB: u32 = 1024 * 1024; + if bytes >= MB && bytes % MB == 0 { + format!("{} MB", bytes / MB) + } else if bytes >= 1024 && bytes % 1024 == 0 { + format!("{} KB", bytes / 1024) + } else { + format!("{} B", bytes) + } + } + fn render_device_info(status: &FullDeviceStatus, theme: &Theme) -> impl IntoElement { let info = &status.info; let config = &status.config; + // RS-Key's rescue FlashInfo is the KV filesystem (credentials & config), + // not the whole chip — label it honestly and surface objects + chip size. + let is_rskey = status.firmware_type == FirmwareType::RSKey; + let flash_label = if is_rskey { + "Storage (credentials & config)" + } else { + "Flash Memory" + }; Card::new() .title("Device Information") @@ -58,7 +90,7 @@ impl HomeViewModel { )) .child(Self::render_kv( "Firmware Version", - format!("v{}", info.firmware_version), + Self::firmware_version_label(status), theme, true, )) @@ -74,6 +106,14 @@ impl HomeViewModel { theme, true, )) + .child(Self::render_kv( + "Manufacturer", + info.manufacturer + .clone() + .unwrap_or_else(|| "Unknown".to_string()), + theme, + false, + )) .child(Self::render_kv( "Product Name", config.product_name.clone(), @@ -92,7 +132,7 @@ impl HomeViewModel { .child( div() .text_color(theme.muted_foreground) - .child("Flash Memory"), + .child(flash_label), ) .child(div().text_color(theme.foreground).child( if let (Some(used), Some(total)) = @@ -112,6 +152,46 @@ impl HomeViewModel { let flash_percent = (used as f32 / total as f32) * 100.0; this.child(Progress::new().value(flash_percent)) }, + ) + .when_some( + info.flash_files.filter(|_| is_rskey), + |this, nfiles| { + this.child( + h_flex() + .justify_between() + .text_sm() + .child( + div() + .text_color(theme.muted_foreground) + .child("Stored objects"), + ) + .child( + div() + .text_color(theme.foreground) + .child(nfiles.to_string()), + ), + ) + }, + ) + .when_some( + info.flash_chip_size.filter(|_| is_rskey), + |this, chip| { + this.child( + h_flex() + .justify_between() + .text_sm() + .child( + div() + .text_color(theme.muted_foreground) + .child("Flash chip"), + ) + .child( + div() + .text_color(theme.foreground) + .child(Self::format_flash_size(chip)), + ), + ) + }, ), ), ) @@ -296,6 +376,11 @@ impl HomeViewModel { config .led_gpio .map(|g| format!("GPIO {}", g)) + .or_else(|| { + config + .effective_led_gpio + .map(|g| format!("GPIO {g} (default)")) + }) .unwrap_or_else(|| "Firmware default".into()), ), ) @@ -326,6 +411,11 @@ impl HomeViewModel { config .touch_timeout .map(|t| format!("{}s", t)) + .or_else(|| { + config + .effective_touch_timeout + .map(|t| format!("{t}s (default)")) + }) .unwrap_or_else(|| "Firmware default".into()), ), ) diff --git a/src/ui/screens/lock/mod.rs b/src/ui/screens/lock/mod.rs new file mode 100644 index 0000000..f7d8eb4 --- /dev/null +++ b/src/ui/screens/lock/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::LockViewModel; diff --git a/src/ui/screens/lock/view.rs b/src/ui/screens/lock/view.rs new file mode 100644 index 0000000..b27a32c --- /dev/null +++ b/src/ui/screens/lock/view.rs @@ -0,0 +1,218 @@ +//! Lock screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::screens::lock::view_model::LockViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body)) + .into_any_element() +} + +impl LockViewModel { + fn action_row( + &self, + title: &'static str, + subtitle: &'static str, + btn: Button, + theme: &Theme, + ) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + ) + .child(btn) + } + + fn lock_key_card(&self, phrase: &str, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let copy = { + let p = phrase.to_string(); + Button::new("lk-copy") + .label("Copy") + .ghost() + .on_click(cx.listener(move |_, _, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(p.clone())); + })) + }; + let clear = Button::new("lk-clear") + .label("Clear from screen") + .ghost() + .on_click(cx.listener(|this, _, _, cx| this.clear_lock_key(cx))); + + Card::new() + .title("Lock key") + .description("Shown once — you need it to unlock after every power-cycle") + .icon(Icon::default().path("icons/key-round.svg")) + .header_right(h_flex().gap_2().child(copy).child(clear)) + .child( + v_flex() + .gap_3() + .child( + div() + .p_3() + .rounded_md() + .bg(rgb(0x18181b)) + .text_color(rgb(0xf59e0b)) + .text_sm() + .child("Lose this and the only recovery is a FIDO factory reset, which destroys this identity. Store it offline."), + ) + .child( + div() + .p_4() + .rounded_lg() + .border_1() + .border_color(theme.border) + .font_family("monospace") + .text_sm() + .child(phrase.to_string()), + ), + ) + .into_any_element() + } +} + +impl Render for LockViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Lock"; + const SUBTITLE: &str = "At-rest soft-lock of the FIDO seed."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let status = self.status; + let locked = status.map(|s| s.locked).unwrap_or(false); + let lock_key = self.lock_key.clone(); + let lock_key_card = lock_key.map(|p| self.lock_key_card(&p, cx)); + + let refresh_btn = Button::new("lk-refresh") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + let enable_btn = Button::new("lk-enable") + .label("Engage lock") + .danger() + .disabled(self.loading || locked) + .on_click(cx.listener(|this, _, window, cx| this.open_enable(window, cx))); + let unlock_btn = Button::new("lk-unlock") + .label("Unlock") + .outline() + .disabled(self.loading || !locked) + .on_click(cx.listener(|this, _, window, cx| this.open_unlock(window, cx))); + let disable_btn = Button::new("lk-disable") + .label("Disable lock") + .outline() + .disabled(self.loading || !locked) + .on_click(cx.listener(|this, _, window, cx| this.open_disable(window, cx))); + + let theme = cx.theme(); + + let status_card = { + let body = match status { + Some(s) => { + let state = if s.locked { + if s.unlocked { + "locked, unlocked for this power-cycle" + } else { + "locked — unlock before any FIDO login" + } + } else { + "not locked (plaintext seed)" + }; + let (dot, color) = if s.locked && !s.unlocked { + ("●", theme.danger) + } else if s.locked { + ("●", theme.green) + } else { + ("●", theme.muted_foreground) + }; + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_center() + .child(div().text_color(color).child(dot)) + .child(div().text_sm().child(format!("State: {state}"))), + ) + .child(div().text_sm().text_color(theme.muted_foreground).child(format!( + "Seed present: {}", + if s.has_seed { "yes" } else { "no" } + ))) + .into_any_element() + } + None => div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Reading lock state…") + .into_any_element(), + }; + Card::new() + .title("Lock status") + .description("Whether the seed is wrapped at rest") + .icon(Icon::default().path("icons/lock.svg")) + .header_right(refresh_btn) + .child(body) + }; + + let actions_card = Card::new() + .title("Actions") + .description("Engage, unlock, or disable the at-rest lock") + .icon(Icon::default().path("icons/lock-open.svg")) + .child( + v_flex() + .gap_2() + .child(self.action_row( + "Engage lock", + "Wrap the seed and reveal a new lock key (PIN + touch)", + enable_btn, + theme, + )) + .child(self.action_row( + "Unlock", + "Load the seed for this power-cycle (lock key)", + unlock_btn, + theme, + )) + .child(self.action_row( + "Disable lock", + "Restore the plaintext seed (lock key + PIN)", + disable_btn, + theme, + )), + ); + + let content = v_flex() + .gap_6() + .child(status_card) + .children(lock_key_card) + .child(actions_card); + + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/lock/view_model.rs b/src/ui/screens/lock/view_model.rs new file mode 100644 index 0000000..d631287 --- /dev/null +++ b/src/ui/screens/lock/view_model.rs @@ -0,0 +1,365 @@ +//! View model for the Lock screen — at-rest soft-lock of the FIDO seed. + +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::models::device::{backup, DeviceEvent, DeviceRepo, FirmwareType}; +use gpui::*; +use gpui_component::button::{ButtonVariant, ButtonVariants}; +use gpui_component::input::InputState; +use gpui_component::WindowExt; + +pub struct LockViewModel { + pub(super) device: Entity, + pub(super) status: Option, + /// The lock-key phrase produced by `enable`, held until cleared. + pub(super) lock_key: Option, + pub(super) loading: bool, + _task: Option>, +} + +impl LockViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + if this.device.read(cx).device_changed { + this.status = None; + this.lock_key = None; + } + this.load(cx); + cx.notify(); + }) + .detach(); + let mut this = Self { + device, + status: None, + lock_key: None, + loading: false, + _task: None, + }; + this.load(cx); + this + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + match &repo.status { + None => AppletGate::Unsupported, + Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported, + Some(_) => AppletGate::Ready, + } + } + + fn load(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::backup_status_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + if let Ok(s) = res { + this.status = Some(s); + } + cx.notify(); + }); + })); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.load(cx); + } + + pub(super) fn clear_lock_key(&mut self, cx: &mut Context) { + self.lock_key = None; + cx.notify(); + } + + fn pin_input(window: &mut Window, cx: &mut Context) -> Entity { + cx.new(|cx| InputState::new(window, cx).masked(true).placeholder("FIDO PIN (required)")) + } + + fn phrase_input(window: &mut Window, cx: &mut Context) -> Entity { + cx.new(|cx| InputState::new(window, cx).placeholder("24-word lock key")) + } + + // ── Enable ────────────────────────────────────────────────────────────── + + pub(super) fn open_enable(&mut self, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + if p.is_empty() { + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog("Engaging Lock", window, cx); + let _ = view.update(cx, |this, cx| this.run_enable(p, status, cx)); + }) + }; + Self::dialog( + "Engage At-Rest Lock", + "Wraps the FIDO seed under a fresh lock key and erases the plaintext. After this, EVERY power-cycle needs an unlock before any FIDO login works. Losing the lock key means the only recovery is a factory reset, which destroys this identity. A FIDO PIN is required; touch to confirm.", + None, + pin, + ("Engage lock", ButtonVariant::Danger), + submit, + window, + cx, + ); + } + + fn run_enable( + &mut self, + pin: String, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Engaging… touch the device (BOOTSEL).", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::lock_enable_blocking(pin) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(phrase) => { + this.lock_key = Some(phrase); + this.load(cx); + let _ = status.update(cx, |d, cx| { + d.set_success( + "Locked — write down the lock key shown below; you need it every power-cycle.".into(), + cx, + ) + }); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + // ── Unlock ────────────────────────────────────────────────────────────── + + pub(super) fn open_unlock(&mut self, window: &mut Window, cx: &mut Context) { + let phrase = Self::phrase_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let phrase = phrase.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let m = phrase.read(cx).text().to_string(); + if m.trim().is_empty() { + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog("Unlocking", window, cx); + let _ = view.update(cx, |this, cx| { + this.run_unit( + move || DeviceRepo::lock_unlock_blocking(m), + "Unlocked — FIDO works until power-off.", + status, + cx, + ); + }); + }) + }; + Self::dialog_phrase_only( + "Unlock Seed", + "Loads the seed into RAM for this power cycle using the lock key.", + phrase, + ("Unlock", ButtonVariant::Primary), + submit, + window, + cx, + ); + } + + // ── Disable ───────────────────────────────────────────────────────────── + + pub(super) fn open_disable(&mut self, window: &mut Window, cx: &mut Context) { + let pin = Self::pin_input(window, cx); + let phrase = Self::phrase_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let pin = pin.clone(); + let phrase = phrase.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let p = pin.read(cx).text().to_string(); + let m = phrase.read(cx).text().to_string(); + if p.is_empty() || m.trim().is_empty() { + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog("Disabling Lock", window, cx); + let _ = view.update(cx, |this, cx| { + this.run_unit( + move || DeviceRepo::lock_disable_blocking(p, m), + "Lock disabled — plaintext seed restored.", + status, + cx, + ); + }); + }) + }; + Self::dialog( + "Disable At-Rest Lock", + "Restores the plaintext seed so FIDO works without an unlock. Needs the lock key and the FIDO PIN; touch to confirm.", + Some(("Lock key (24 words)", phrase)), + pin, + ("Disable lock", ButtonVariant::Primary), + submit, + window, + cx, + ); + } + + fn run_unit( + &mut self, + op: impl FnOnce() -> Result<(), String> + Send + 'static, + ok_msg: &'static str, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Working… touch the device (BOOTSEL) if it blinks.", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op() }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx)); + this.load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + /// Dialog with an optional text field above a required PIN field. + #[allow(clippy::too_many_arguments)] + fn dialog( + title: &'static str, + body: &'static str, + extra: Option<(&'static str, Entity)>, + pin: Entity, + action: (&'static str, ButtonVariant), + submit: std::rc::Rc, + window: &mut Window, + cx: &mut Context, + ) { + window.open_dialog(cx, move |dialog, _w, _| { + let pin = pin.clone(); + let extra = extra.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let (action_label, action_variant) = action; + let mut fields = gpui_component::v_flex().gap_3().pb_2(); + if let Some((label, input)) = &extra { + fields = fields + .child(label.to_string()) + .child(gpui_component::input::Input::new(input)); + } + fields = fields + .child("FIDO PIN") + .child(gpui_component::input::Input::new(&pin)); + dialog + .title(title) + .child(body) + .child(fields) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("go") + .with_variant(action_variant) + .label(action_label) + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + /// Dialog with a single text field (unlock — no PIN). + fn dialog_phrase_only( + title: &'static str, + body: &'static str, + phrase: Entity, + action: (&'static str, ButtonVariant), + submit: std::rc::Rc, + window: &mut Window, + cx: &mut Context, + ) { + window.open_dialog(cx, move |dialog, _w, _| { + let phrase = phrase.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let (action_label, action_variant) = action; + dialog + .title(title) + .child(body) + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child("Lock key (24 words)") + .child(gpui_component::input::Input::new(&phrase)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("go") + .with_variant(action_variant) + .label(action_label) + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } +} diff --git a/src/ui/screens/mod.rs b/src/ui/screens/mod.rs index 0326cc7..dd6a941 100644 --- a/src/ui/screens/mod.rs +++ b/src/ui/screens/mod.rs @@ -1,5 +1,14 @@ pub mod about; +pub mod accounts; +pub mod attestation; +pub mod audit; +pub mod backup; pub mod config; pub mod home; +pub mod lock; +pub mod offboard; +pub mod openpgp; pub mod passkeys; +pub mod piv; pub mod security; +pub mod slots; diff --git a/src/ui/screens/offboard/mod.rs b/src/ui/screens/offboard/mod.rs new file mode 100644 index 0000000..f99a128 --- /dev/null +++ b/src/ui/screens/offboard/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::{OffboardEvent, OffboardViewModel}; diff --git a/src/ui/screens/offboard/view.rs b/src/ui/screens/offboard/view.rs new file mode 100644 index 0000000..02e8cbf --- /dev/null +++ b/src/ui/screens/offboard/view.rs @@ -0,0 +1,151 @@ +//! Offboard screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::screens::offboard::view_model::OffboardViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body)) + .into_any_element() +} + +impl OffboardViewModel { + fn report_card(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let Some(r) = &self.report else { + return div().into_any_element(); + }; + + let mut rows = Vec::new(); + for s in &r.steps { + let (mark, color) = if s.ok { + ("✓", theme.green) + } else { + ("✗", theme.danger) + }; + rows.push( + h_flex() + .gap_3() + .py_1() + .items_center() + .child(div().w(px(16.)).text_color(color).child(mark)) + .child(div().w(px(120.)).font_medium().child(s.name.clone())) + .child(div().flex_1().text_sm().text_color(theme.muted_foreground).child(s.detail.clone())) + .into_any_element(), + ); + } + + let (verdict, vcolor) = if !r.all_ok() { + ("Finished with failures", theme.danger) + } else if r.signed { + ("All wiped · receipt signed", theme.green) + } else { + ("All wiped · receipt unsigned", theme.muted_foreground) + }; + + let save_btn = Button::new("off-save") + .label("Save receipt (JSON)") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.save_receipt(window, cx))); + + let mut col = v_flex() + .gap_3() + .child( + h_flex() + .gap_2() + .items_center() + .child(div().w(px(10.)).h(px(10.)).rounded_full().bg(vcolor)) + .child(div().font_semibold().text_color(vcolor).child(verdict)), + ) + .child(v_flex().gap_0p5().children(rows)); + if let Some(fp) = &r.fingerprint { + col = col.child( + v_flex() + .gap_0p5() + .child(div().text_xs().text_color(theme.muted_foreground).child("Attestation fingerprint (match against inventory)")) + .child(div().font_family("monospace").text_xs().child(fp.clone())), + ); + } + + Card::new() + .title("Offboard receipt") + .description("Per-applet wipe results and the signed checkpoint") + .icon(Icon::default().path("icons/scroll-text.svg")) + .header_right(save_btn) + .child(col) + .into_any_element() + } +} + +impl Render for OffboardViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Offboard"; + const SUBTITLE: &str = "Guided full-device wipe with a signed receipt."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let serial = self.serial(cx); + let report_card = self.report.as_ref().map(|_| self.report_card(cx)); + + let offboard_btn = Button::new("off-run") + .label("Offboard device") + .danger() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_confirm(window, cx))); + + let theme = cx.theme(); + + let warn_card = Card::new() + .title("Decommission") + .description("Wipe every applet and finish with a cryptographic receipt") + .icon(Icon::default().path("icons/trash-2.svg")) + .child( + v_flex() + .gap_3() + .child( + div() + .p_3() + .rounded_md() + .bg(rgb(0x18181b)) + .text_color(rgb(0xf59e0b)) + .text_sm() + .child("Erases OTP, OATH, PIV, OpenPGP, the FIDO seed, passkeys, PINs, and org attestation. Irreversible. Needs the CCID interface and several touches."), + ) + .child( + h_flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(format!("Offboard {serial}"))) + .child(div().text_sm().text_color(theme.muted_foreground).child("Wipe all applets, then sign a receipt")), + ) + .child(offboard_btn), + ), + ); + + let content = v_flex().gap_6().child(warn_card).children(report_card); + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/offboard/view_model.rs b/src/ui/screens/offboard/view_model.rs new file mode 100644 index 0000000..2cb5218 --- /dev/null +++ b/src/ui/screens/offboard/view_model.rs @@ -0,0 +1,215 @@ +//! View model for the Offboard screen — guided full-device wipe + signed receipt. + +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, FirmwareType, OffboardReport}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::input::InputState; +use gpui_component::WindowExt; + +pub struct OffboardViewModel { + pub(super) device: Entity, + pub(super) report: Option, + pub(super) loading: bool, + _task: Option>, +} + +pub enum OffboardEvent { + Notification(String), +} + +impl EventEmitter for OffboardViewModel {} + +impl OffboardViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + if this.device.read(cx).device_changed { + this.report = None; + cx.notify(); + } + }) + .detach(); + Self { + device, + report: None, + loading: false, + _task: None, + } + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + match &repo.status { + None => AppletGate::Unsupported, + Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported, + Some(_) => AppletGate::Ready, + } + } + + pub(super) fn serial(&self, cx: &App) -> String { + self.device + .read(cx) + .status + .as_ref() + .map(|s| s.info.serial.clone()) + .unwrap_or_default() + } + + // ── Confirm + run ─────────────────────────────────────────────────────── + + pub(super) fn open_confirm(&mut self, window: &mut Window, cx: &mut Context) { + let serial = self.serial(cx); + let confirm = cx.new(|cx| { + InputState::new(window, cx).placeholder("Type OFFBOARD to confirm") + }); + let view = cx.entity().downgrade(); + let submit = { + let confirm = confirm.clone(); + let serial = serial.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + if confirm.read(cx).text().to_string().trim() != "OFFBOARD" { + let _ = view.update(cx, |_, cx| { + cx.emit(OffboardEvent::Notification("Type OFFBOARD exactly to confirm".into())) + }); + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog("Offboarding", window, cx); + let serial = serial.clone(); + let _ = view.update(cx, |this, cx| this.run_offboard(serial, status, cx)); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let confirm = confirm.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let serial = serial.clone(); + dialog + .title("Offboard Device") + .child(format!( + "This ERASES everything on device {serial}: OTP slots, OATH, PIV, OpenPGP, the FIDO seed, passkeys, PINs, and org attestation — then writes a signed wipe receipt. It cannot be undone and needs several touches." + )) + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child("Confirmation") + .child(gpui_component::input::Input::new(&confirm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("go") + .danger() + .label("Offboard") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + fn run_offboard( + &mut self, + serial: String, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + let _ = status.update(cx, |d, cx| { + d.set_loading("Wiping… touch the device (BOOTSEL) when it blinks (several times).", cx) + }); + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::offboard_blocking(serial) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(report) => { + let msg = if report.all_ok() { + if report.signed { + "Offboarded — all applets wiped, receipt signed.".to_string() + } else { + "Offboarded — all applets wiped (receipt UNSIGNED: no OTP DEVK).".to_string() + } + } else { + format!("Offboard finished WITH FAILURES: {:?}", report.failures()) + }; + this.report = Some(report); + let _ = status.update(cx, |d, cx| d.set_success(msg, cx)); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(e, cx)); + } + } + cx.notify(); + }); + })); + } + + // ── Save receipt ──────────────────────────────────────────────────────── + + pub(super) fn save_receipt(&mut self, _window: &mut Window, cx: &mut Context) { + let Some(report) = self.report.clone() else { + return; + }; + let default_dir = std::env::var("HOME").map(std::path::PathBuf::from).unwrap_or_default(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let json = report.to_json(&iso_utc(now)); + let receiver = cx.prompt_for_new_path( + &default_dir, + Some(&format!("offboard-{}.json", report.serial)), + ); + let view = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(path))) = receiver.await else { + return; + }; + let _ = view.update(cx, |_, cx| match std::fs::write(&path, json) { + Ok(_) => cx.emit(OffboardEvent::Notification(format!( + "Receipt saved to {}", + path.display() + ))), + Err(e) => cx.emit(OffboardEvent::Notification(format!("Save failed: {e}"))), + }); + })); + } +} + +/// Format epoch seconds as an ISO-8601 UTC timestamp (civil date, Hinnant). +fn iso_utc(secs: u64) -> String { + let days = (secs / 86400) as i64; + let rem = secs % 86400; + let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") +} diff --git a/src/ui/screens/openpgp/mod.rs b/src/ui/screens/openpgp/mod.rs new file mode 100644 index 0000000..5fbe894 --- /dev/null +++ b/src/ui/screens/openpgp/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::{OpenPgpEvent, OpenPgpViewModel}; diff --git a/src/ui/screens/openpgp/view.rs b/src/ui/screens/openpgp/view.rs new file mode 100644 index 0000000..5885765 --- /dev/null +++ b/src/ui/screens/openpgp/view.rs @@ -0,0 +1,298 @@ +//! OpenPGP screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::models::device::openpgp; +use crate::ui::screens::openpgp::view_model::OpenPgpViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) + .into_any_element() +} + +fn kv(label: &str, value: String, theme: &Theme) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_sm().text_color(theme.muted_foreground).child(label.to_string())) + .child(div().text_sm().font_medium().child(value)) +} + +/// Group a fingerprint hex string into 4-char blocks for readability. +fn group_fp(fp: &str) -> String { + fp.to_uppercase() + .as_bytes() + .chunks(4) + .map(|c| String::from_utf8_lossy(c).to_string()) + .collect::>() + .join(" ") +} + +impl OpenPgpViewModel { + fn render_key_row(&self, k: openpgp::PgpKey, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let slot = k.slot; + let d = self.loading; + let has_fp = k.fingerprint.chars().any(|c| c != '0'); + let status = if k.present { + format!("{} · touch {}", k.algo, if k.touch { "on" } else { "off" }) + } else { + "Empty".to_string() + }; + + let mut col = v_flex() + .gap_0p5() + .child(div().font_medium().child(slot.label())) + .child( + div() + .text_sm() + .text_color(if k.present { theme.foreground } else { theme.muted_foreground }) + .child(status), + ); + if has_fp { + col = col.child( + div() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(group_fp(&k.fingerprint)), + ); + } + + v_flex() + .gap_3() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child(col) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new(SharedString::from(format!("gen-{}", slot.label()))) + .label(if k.present { "Regenerate" } else { "Generate" }) + .outline() + .disabled(d) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_generate(slot, window, cx); + })), + ) + .child( + Button::new(SharedString::from(format!("touch-{}", slot.label()))) + .label("Touch policy") + .ghost() + .disabled(d) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_touch(slot, window, cx); + })), + ), + ) + .into_any_element() + } + + fn action_row( + &self, + title: &'static str, + subtitle: &'static str, + btn: Button, + theme: &Theme, + ) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + ) + .child(btn) + } +} + +impl Render for OpenPgpViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "OpenPGP"; + const SUBTITLE: &str = "OpenPGP smart card — keys, PINs, and cardholder."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let info = self.info.clone(); + let keys = info.as_ref().map(|i| i.keys.clone()).unwrap_or_default(); + + let mut key_rows = Vec::new(); + for k in keys { + key_rows.push(self.render_key_row(k, cx)); + } + + let refresh_btn = Button::new("pgp-refresh") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + let change_user_btn = Button::new("pgp-change-user") + .label("Change User PIN") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_change_user_pin(window, cx))); + let change_admin_btn = Button::new("pgp-change-admin") + .label("Change Admin PIN") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_change_admin_pin(window, cx))); + let unblock_code_btn = Button::new("pgp-unblock-code") + .label("Reset code") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_unblock_with_code(window, cx))); + let unblock_admin_btn = Button::new("pgp-unblock-admin") + .label("Admin PIN") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_unblock_with_admin(window, cx))); + let reset_code_btn = Button::new("pgp-set-rc") + .label("Set reset code") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_set_reset_code(window, cx))); + let cardholder_btn = Button::new("pgp-cardholder") + .label("Edit") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_cardholder(window, cx))); + let reset_btn = Button::new("pgp-reset") + .label("Reset OpenPGP applet") + .danger() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_reset_dialog(window, cx))); + + let theme = cx.theme(); + + let info_card = { + let body = match &info { + Some(i) => { + let serial = if i.serial != 0 { i.serial.to_string() } else { "—".into() }; + let field = |s: &str| if s.is_empty() { "—".to_string() } else { s.to_string() }; + div() + .grid() + .grid_cols(2) + .gap_4() + .child(kv( + "Version", + format!("{}.{}.{}", i.version[0], i.version[1], i.version[2]), + theme, + )) + .child(kv("Serial", serial, theme)) + .child(kv("Name", field(&i.name), theme)) + .child(kv("Login", field(&i.login), theme)) + .child(kv("URL", field(&i.url), theme)) + .child(kv( + "PIN tries (user / reset / admin)", + format!("{} / {} / {}", i.pw1_retries, i.rc_retries, i.pw3_retries), + theme, + )) + .into_any_element() + } + None => div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Reading card…") + .into_any_element(), + }; + Card::new() + .title("Card information") + .description("OpenPGP card status") + .icon(Icon::default().path("icons/cpu.svg")) + .header_right(refresh_btn) + .child(body) + }; + + let keys_card = Card::new() + .title("Keys") + .description("Signature / Encryption / Authentication slots") + .icon(Icon::default().path("icons/key.svg")) + .child(v_flex().gap_2().children(key_rows)); + + let pin_card = Card::new() + .title("PINs") + .description("User PIN (PW1) and admin PIN (PW3)") + .icon(Icon::default().path("icons/lock.svg")) + .child( + v_flex() + .gap_2() + .child(self.action_row("User PIN", "Change the user PIN (PW1)", change_user_btn, theme)) + .child(self.action_row("Admin PIN", "Change the admin PIN (PW3)", change_admin_btn, theme)) + .child(self.action_row( + "Unblock user PIN", + "Reset a blocked user PIN with the reset code", + unblock_code_btn, + theme, + )) + .child(self.action_row( + "Unblock via admin", + "Reset a blocked user PIN with the admin PIN", + unblock_admin_btn, + theme, + )) + .child(self.action_row( + "Reset code", + "Set or clear the reset code (needs the admin PIN)", + reset_code_btn, + theme, + )), + ); + + let cardholder_card = Card::new() + .title("Cardholder") + .description("Name, login, and URL stored on the card") + .icon(Icon::default().path("icons/user.svg")) + .child(self.action_row( + "Cardholder details", + "Edit the cardholder name, login, and URL", + cardholder_btn, + theme, + )); + + let reset_card = Card::new() + .title("Reset") + .description("Erase all OpenPGP keys and data") + .icon(Icon::default().path("icons/trash.svg")) + .child(self.action_row( + "Factory reset OpenPGP", + "Blocks both PINs then wipes everything. Cannot be undone.", + reset_btn, + theme, + )); + + let content = v_flex() + .gap_6() + .child(info_card) + .child(keys_card) + .child(pin_card) + .child(cardholder_card) + .child(reset_card); + + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/openpgp/view_model.rs b/src/ui/screens/openpgp/view_model.rs new file mode 100644 index 0000000..14d77bb --- /dev/null +++ b/src/ui/screens/openpgp/view_model.rs @@ -0,0 +1,573 @@ +//! View model for the OpenPGP screen — card status, PIN management, per-key +//! touch policy, on-device key generation, cardholder editing, and reset. + +use crate::error::PFError; +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::components::form::{select_state, selected_key}; +use crate::ui::models::device::{openpgp, DeviceEvent, DeviceRepo, USB_CAP_OPENPGP}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::WindowExt; +use openpgp::PgpSlot; + +const OPT_TOUCH: &[(&str, u8)] = &[("Off", 0), ("On", 1)]; +/// OpenPGP sex (DO 5F35): the byte is an ISO-5218-style ASCII digit. +const OPT_SEX: &[(&str, u8)] = &[("Not announced", 0x39), ("Male", 0x31), ("Female", 0x32)]; + +pub struct OpenPgpViewModel { + pub(super) device: Entity, + pub(super) info: Option, + pub(super) loaded: bool, + pub(super) loading: bool, + _task: Option>, +} + +pub enum OpenPgpEvent { + Notification(String), +} + +impl EventEmitter for OpenPgpViewModel {} + +impl OpenPgpViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + this.on_device_event(cx); + }) + .detach(); + let mut this = Self { + device, + info: None, + loaded: false, + loading: false, + _task: None, + }; + this.load(cx); + this + } + + fn on_device_event(&mut self, cx: &mut Context) { + if self.device.read(cx).device_changed { + self.info = None; + self.loaded = false; + } + self.load(cx); + cx.notify(); + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + if repo.status.is_none() { + return AppletGate::Unsupported; + } + match repo.openpgp_features() { + None => AppletGate::Unsupported, + Some(_) if !repo.ccid_on() => AppletGate::CcidOff, + Some(_) if !repo.applet_enabled(USB_CAP_OPENPGP) => AppletGate::Disabled("OpenPGP"), + Some(_) => AppletGate::Ready, + } + } + + /// Whether the firmware advertises elliptic-curve keys (else RSA-only). + fn ecc(&self, cx: &App) -> bool { + self.device.read(cx).openpgp_features().map(|f| f.ecc).unwrap_or(false) + } + + fn load(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::openpgp_read_info_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(info) => { + this.info = Some(info); + this.loaded = true; + } + Err(e) => { + log::warn!("OpenPGP read failed: {e}"); + cx.emit(OpenPgpEvent::Notification(format!("OpenPGP: {e}"))); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.load(cx); + } + + /// Run a blocking op, report on `status`, and reload on success. + fn run( + &mut self, + op: impl FnOnce() -> Result<(), PFError> + Send + 'static, + ok_msg: &'static str, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op() }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx)); + this.load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + // ── PIN management ────────────────────────────────────────────────────── + + pub(super) fn open_change_user_pin(&mut self, window: &mut Window, cx: &mut Context) { + self.two_secret_dialog( + "Change User PIN", + "Current PIN (PW1)", + "New PIN", + None, + window, + cx, + DeviceRepo::openpgp_change_user_pin_blocking, + "User PIN changed.", + ); + } + + pub(super) fn open_change_admin_pin(&mut self, window: &mut Window, cx: &mut Context) { + self.two_secret_dialog( + "Change Admin PIN", + "Current admin PIN (PW3)", + "New admin PIN", + None, + window, + cx, + DeviceRepo::openpgp_change_admin_pin_blocking, + "Admin PIN changed.", + ); + } + + pub(super) fn open_unblock_with_code(&mut self, window: &mut Window, cx: &mut Context) { + self.two_secret_dialog( + "Unblock with Reset Code", + "Reset code", + "New user PIN", + None, + window, + cx, + DeviceRepo::openpgp_unblock_with_code_blocking, + "User PIN unblocked.", + ); + } + + pub(super) fn open_unblock_with_admin(&mut self, window: &mut Window, cx: &mut Context) { + self.two_secret_dialog( + "Unblock with Admin PIN", + "Admin PIN (PW3)", + "New user PIN", + Some(openpgp::DEFAULT_PW3), + window, + cx, + DeviceRepo::openpgp_unblock_with_admin_blocking, + "User PIN unblocked.", + ); + } + + pub(super) fn open_set_reset_code(&mut self, window: &mut Window, cx: &mut Context) { + self.two_secret_dialog( + "Set Reset Code", + "Admin PIN (PW3)", + "New reset code", + Some(openpgp::DEFAULT_PW3), + window, + cx, + DeviceRepo::openpgp_set_reset_code_blocking, + "Reset code updated.", + ); + } + + /// A two-masked-field dialog running a blocking `op(a, b)`. `prefill_a` + /// seeds the first field (e.g. the default admin PIN) for convenience. + #[allow(clippy::too_many_arguments)] + fn two_secret_dialog( + &mut self, + title: &'static str, + label_a: &'static str, + label_b: &'static str, + prefill_a: Option<&'static str>, + window: &mut Window, + cx: &mut Context, + op: impl Fn(String, String) -> Result<(), PFError> + Send + Clone + 'static, + ok_msg: &'static str, + ) { + let a = cx.new(|cx| { + let st = gpui_component::input::InputState::new(window, cx).masked(true); + match prefill_a { + Some(v) => st.default_value(v), + None => st, + } + }); + let b = cx.new(|cx| gpui_component::input::InputState::new(window, cx).masked(true)); + let view = cx.entity().downgrade(); + let submit = { + let a = a.clone(); + let b = b.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let av = a.read(cx).text().to_string(); + let bv = b.read(cx).text().to_string(); + if av.is_empty() || bv.is_empty() { + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog(title, window, cx); + let op = op.clone(); + let _ = view.update(cx, |this, cx| { + this.run(move || op(av, bv), ok_msg, status, cx); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let a = a.clone(); + let b = b.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(title) + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child(label_a) + .child(gpui_component::input::Input::new(&a)) + .child(label_b) + .child(gpui_component::input::Input::new(&b)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Save") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Generate ──────────────────────────────────────────────────────────── + + pub(super) fn open_generate(&mut self, slot: PgpSlot, window: &mut Window, cx: &mut Context) { + let algos: &'static [(&str, u8)] = if self.ecc(cx) { + openpgp::GENERATE_ALGOS + } else { + &openpgp::GENERATE_ALGOS[..3] + }; + let algo_sel = select_state(window, cx, algos, 0); + let admin = admin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let algo_sel = algo_sel.clone(); + let admin = admin.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let admin_pin = admin.read(cx).text().to_string(); + if admin_pin.is_empty() { + return; + } + let choice = selected_key(&algo_sel, algos, cx); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Generating Key", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || DeviceRepo::openpgp_generate_blocking(admin_pin, slot, choice), + "Key generated. Use GnuPG to set the fingerprint and publish the key.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let algo_sel = algo_sel.clone(); + let admin = admin.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(format!("Generate — {}", slot.label())) + .child("Generates a new key pair in this slot (overwrites any existing key). This can take several seconds for RSA.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child("Algorithm") + .child( + gpui_component::select::Select::new(&algo_sel) + .w_full() + .bg(rgb(0x222225)), + ) + .child("Admin PIN (PW3)") + .child(gpui_component::input::Input::new(&admin)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("gen") + .primary() + .label("Generate") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Touch policy ────────────────────────────────────────────────────────── + + pub(super) fn open_touch(&mut self, slot: PgpSlot, window: &mut Window, cx: &mut Context) { + let current = self + .info + .as_ref() + .and_then(|i| i.keys.iter().find(|k| k.slot == slot)) + .map(|k| k.touch) + .unwrap_or(false); + let touch_sel = select_state(window, cx, OPT_TOUCH, if current { 1 } else { 0 }); + let admin = admin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let touch_sel = touch_sel.clone(); + let admin = admin.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let admin_pin = admin.read(cx).text().to_string(); + if admin_pin.is_empty() { + return; + } + let on = selected_key(&touch_sel, OPT_TOUCH, cx) == 1; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Updating Touch Policy", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || DeviceRepo::openpgp_set_touch_blocking(admin_pin, slot, on), + "Touch policy updated.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let touch_sel = touch_sel.clone(); + let admin = admin.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(format!("Touch — {}", slot.label())) + .child("When on, this key requires a physical touch for every operation.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child("Touch requirement") + .child( + gpui_component::select::Select::new(&touch_sel) + .w_full() + .bg(rgb(0x222225)), + ) + .child("Admin PIN (PW3)") + .child(gpui_component::input::Input::new(&admin)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Save") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Cardholder ──────────────────────────────────────────────────────────── + + pub(super) fn open_cardholder(&mut self, window: &mut Window, cx: &mut Context) { + let (cur_name, cur_login, cur_url, cur_lang, cur_sex) = self + .info + .as_ref() + .map(|i| { + (i.name.clone(), i.login.clone(), i.url.clone(), i.lang.clone(), i.sex) + }) + .unwrap_or((String::new(), String::new(), String::new(), String::new(), 0x39)); + let name = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).default_value(cur_name) + }); + let login = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).default_value(cur_login) + }); + let url = + cx.new(|cx| gpui_component::input::InputState::new(window, cx).default_value(cur_url)); + let lang = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).default_value(cur_lang) + }); + let sex_row = OPT_SEX.iter().position(|(_, k)| *k == cur_sex).unwrap_or(0); + let sex = select_state(window, cx, OPT_SEX, sex_row); + let admin = admin_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let name = name.clone(); + let login = login.clone(); + let url = url.clone(); + let lang = lang.clone(); + let sex = sex.clone(); + let admin = admin.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let admin_pin = admin.read(cx).text().to_string(); + if admin_pin.is_empty() { + return; + } + let name_v = name.read(cx).text().to_string(); + let login_v = login.read(cx).text().to_string(); + let url_v = url.read(cx).text().to_string(); + let lang_v = lang.read(cx).text().to_string(); + let sex_v = selected_key(&sex, OPT_SEX, cx); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Saving Cardholder", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || { + DeviceRepo::openpgp_set_cardholder_blocking( + admin_pin, name_v, login_v, url_v, lang_v, sex_v, + ) + }, + "Cardholder details saved.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let name = name.clone(); + let login = login.clone(); + let url = url.clone(); + let lang = lang.clone(); + let sex = sex.clone(); + let admin = admin.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title("Edit Cardholder") + .child("Cardholder metadata stored on the card. Requires the admin PIN.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child("Name") + .child(gpui_component::input::Input::new(&name)) + .child("Login") + .child(gpui_component::input::Input::new(&login)) + .child("URL") + .child(gpui_component::input::Input::new(&url)) + .child("Language (ISO-639, e.g. en)") + .child(gpui_component::input::Input::new(&lang)) + .child("Sex") + .child(gpui_component::select::Select::new(&sex)) + .child("Admin PIN (PW3)") + .child(gpui_component::input::Input::new(&admin)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Save") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Reset ───────────────────────────────────────────────────────────────── + + pub(super) fn open_reset_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view = cx.entity().downgrade(); + dialog::open_confirm( + "Reset OpenPGP Applet", + "This blocks both PINs, then factory-resets the OpenPGP applet — deleting ALL keys and restoring the default PINs (123456 / 12345678). This cannot be undone.".to_string(), + "Reset", + gpui_component::button::ButtonVariant::Danger, + window, + cx, + move |_dh, window, cx| { + window.close_dialog(cx); + let status = dialog::open_status_dialog("Resetting OpenPGP", window, cx); + let _ = view.update(cx, |this, cx| { + this.run(DeviceRepo::openpgp_reset_blocking, "OpenPGP applet reset.", status, cx); + }); + }, + ); + } +} + +/// A masked input seeded with the default admin PIN for management dialogs. +fn admin_input( + window: &mut Window, + cx: &mut Context, +) -> Entity { + cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .masked(true) + .default_value(openpgp::DEFAULT_PW3) + }) +} diff --git a/src/ui/screens/piv/mod.rs b/src/ui/screens/piv/mod.rs new file mode 100644 index 0000000..b6841ca --- /dev/null +++ b/src/ui/screens/piv/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::{PivEvent, PivViewModel}; diff --git a/src/ui/screens/piv/view.rs b/src/ui/screens/piv/view.rs new file mode 100644 index 0000000..fd97a83 --- /dev/null +++ b/src/ui/screens/piv/view.rs @@ -0,0 +1,300 @@ +//! PIV screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::models::device::piv; +use crate::ui::screens::piv::view_model::PivViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) + .into_any_element() +} + +fn kv(label: &str, value: String, theme: &Theme) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_sm().text_color(theme.muted_foreground).child(label.to_string())) + .child(div().text_sm().font_medium().child(value)) +} + +fn origin_label(o: u8) -> &'static str { + match o { + piv::ORIGIN_GENERATED => "generated", + piv::ORIGIN_IMPORTED => "imported", + _ => "?", + } +} + +impl PivViewModel { + fn render_slot_row(&self, s: piv::SlotStatus, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let slot = s.slot; + let has_key = s.meta.is_some(); + let is_generated = s.meta.map(|m| m.origin == piv::ORIGIN_GENERATED).unwrap_or(false); + let status_text = match s.meta { + Some(m) => format!("{} · {}", piv::algo_label(m.algo), origin_label(m.origin)), + None => "Empty".to_string(), + }; + let cert = if s.has_cert { " · certificate" } else { "" }; + let d = self.loading; + + macro_rules! btn { + ($id:expr, $label:expr, $method:ident) => { + Button::new(SharedString::from(format!("{}-{slot:02x}", $id))) + .label($label) + .ghost() + .disabled(d) + .on_click(cx.listener(move |this, _, window, cx| this.$method(slot, window, cx))) + .into_any_element() + }; + } + + let mut btns: Vec = vec![ + Button::new(SharedString::from(format!("gen-{slot:02x}"))) + .label(if has_key { "Regenerate" } else { "Generate" }) + .outline() + .disabled(d) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_generate_dialog(slot, window, cx); + })) + .into_any_element(), + btn!("impk", "Import key", open_import_key), + btn!("impc", "Import cert", open_import_cert), + ]; + if s.has_cert { + btns.push(btn!("exp", "Export cert", open_export_cert)); + } + if is_generated { + btns.push(btn!("att", "Attest", open_attest)); + } + if has_key { + btns.push(btn!("mv", "Move", open_move_key)); + } + if s.has_cert { + btns.push(btn!("delc", "Delete cert", open_delete_cert)); + } + if has_key { + btns.push( + Button::new(SharedString::from(format!("delk-{slot:02x}"))) + .icon(Icon::default().path("icons/trash-2.svg")) + .label("Delete key") + .ghost() + .disabled(d) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_delete_key(slot, window, cx); + })) + .into_any_element(), + ); + } + + v_flex() + .gap_3() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(piv::slot_label(slot))) + .child( + div() + .text_sm() + .text_color(if has_key { + theme.foreground + } else { + theme.muted_foreground + }) + .child(format!("{status_text}{cert}")), + ), + ) + .child(h_flex().gap_2().flex_wrap().children(btns)) + .into_any_element() + } + + fn action_row( + &self, + title: &'static str, + subtitle: &'static str, + btn: Button, + theme: &Theme, + ) -> impl IntoElement { + h_flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + ) + .child(btn) + } +} + +impl Render for PivViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "PIV"; + const SUBTITLE: &str = "Smart-card certificates and keys (PIV)."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let info = self.info.clone(); + let slots = info.as_ref().map(|i| i.slots.clone()).unwrap_or_default(); + + // Slot rows (mutable cx). + let mut slot_rows = Vec::new(); + for s in slots { + slot_rows.push(self.render_slot_row(s, cx)); + } + + // Buttons. + let refresh_btn = Button::new("piv-refresh") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + let change_pin_btn = Button::new("piv-change-pin") + .label("Change PIN") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_change_pin(false, window, cx))); + let change_puk_btn = Button::new("piv-change-puk") + .label("Change PUK") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_change_pin(true, window, cx))); + let unblock_btn = Button::new("piv-unblock") + .label("Unblock PIN") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_unblock_pin(window, cx))); + let retries_btn = Button::new("piv-retries") + .label("Set retries") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_set_retries(window, cx))); + let mgm_btn = Button::new("piv-mgm") + .label("Change key") + .outline() + .on_click(cx.listener(|this, _, window, cx| this.open_change_mgm(window, cx))); + let reset_btn = Button::new("piv-reset") + .label("Reset PIV applet") + .danger() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_reset_dialog(window, cx))); + + let theme = cx.theme(); + + // Card information. + let info_card = { + let body = match &info { + Some(i) => { + let pin = i + .pin + .map(|p| format!("{}/{}{}", p.left, p.total, if p.is_default { " (default)" } else { "" })) + .unwrap_or_else(|| "—".into()); + let puk = i + .puk + .map(|p| format!("{}/{}{}", p.left, p.total, if p.is_default { " (default)" } else { "" })) + .unwrap_or_else(|| "—".into()); + let mgm = format!( + "{}{}", + piv::algo_label(i.mgm_algo), + if i.mgm_default { " (default)" } else { "" } + ); + div() + .grid() + .grid_cols(2) + .gap_4() + .child(kv("Firmware", format!("{}.{}.{}", i.version[0], i.version[1], i.version[2]), theme)) + .child(kv("Serial", i.serial.to_string(), theme)) + .child(kv("PIN tries", pin, theme)) + .child(kv("PUK tries", puk, theme)) + .child(kv("Management key", mgm, theme)) + .into_any_element() + } + None => div().text_sm().text_color(theme.muted_foreground).child("Reading card…").into_any_element(), + }; + Card::new() + .title("Card information") + .description("PIV card status") + .icon(Icon::default().path("icons/cpu.svg")) + .header_right(refresh_btn) + .child(body) + }; + + let slots_card = Card::new() + .title("Key slots") + .description("Certificate slots 9A / 9C / 9D / 9E") + .icon(Icon::default().path("icons/key.svg")) + .child(v_flex().gap_2().children(slot_rows)); + + let pin_card = Card::new() + .title("PIN & PUK") + .description("Manage the PIV PIN and PUK") + .icon(Icon::default().path("icons/lock.svg")) + .child( + v_flex() + .gap_2() + .child(self.action_row("PIN", "Change the 6–8 digit PIV PIN", change_pin_btn, theme)) + .child(self.action_row("PUK", "Change the PIN Unblock Key", change_puk_btn, theme)) + .child(self.action_row("Unblock", "Reset a blocked PIN using the PUK", unblock_btn, theme)) + .child(self.action_row("Retry limits", "Set PIN/PUK retries (resets both to defaults)", retries_btn, theme)), + ); + + let mgm_card = Card::new() + .title("Management key") + .description("The key that authorises key and certificate changes") + .icon(Icon::default().path("icons/key-round.svg")) + .child(self.action_row( + "Management key", + "Change the PIV management key", + mgm_btn, + theme, + )); + + let reset_card = Card::new() + .title("Reset") + .description("Erase all PIV keys and certificates") + .icon(Icon::default().path("icons/trash.svg")) + .child(self.action_row( + "Factory reset PIV", + "Blocks PIN+PUK then wipes everything. Cannot be undone.", + reset_btn, + theme, + )); + + let content = v_flex() + .gap_6() + .child(info_card) + .child(slots_card) + .child(pin_card) + .child(mgm_card) + .child(reset_card); + + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/piv/view_model.rs b/src/ui/screens/piv/view_model.rs new file mode 100644 index 0000000..74c6770 --- /dev/null +++ b/src/ui/screens/piv/view_model.rs @@ -0,0 +1,1094 @@ +//! View model for the PIV screen — slot status, key generation, PIN/PUK +//! management, certificate export, and factory reset. + +use crate::error::PFError; +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::components::form::{select_state, selected_key, LabeledU8}; +use crate::ui::models::device::{piv, DeviceEvent, DeviceRepo, MgmAuth, USB_CAP_PIV}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::select::SelectState; +use gpui_component::WindowExt; + +const OPT_ALGO: &[(&str, u8)] = &[ + ("ECC P-256", 0x11), + ("ECC P-384", 0x14), + ("Ed25519", 0xE0), + ("X25519", 0xE1), + ("RSA-2048", 0x07), + ("RSA-3072", 0x05), + ("RSA-4096", 0x16), +]; +const OPT_PIN_POLICY: &[(&str, u8)] = + &[("Default", 0), ("Never", 1), ("Once", 2), ("Always", 3)]; +const OPT_TOUCH_POLICY: &[(&str, u8)] = + &[("Default", 0), ("Never", 1), ("Always", 2), ("Cached", 3)]; +const OPT_TRIES: &[(&str, u8)] = &[("3", 3), ("5", 5), ("8", 8), ("10", 10)]; +const OPT_MGM_ALGO: &[(&str, u8)] = &[("AES-192", 0x0A), ("AES-128", 0x08), ("AES-256", 0x0C)]; +const OPT_MGM_TOUCH: &[(&str, u8)] = &[("Not required", 0), ("Touch required", 1)]; +const OPT_SLOTS: &[(&str, u8)] = &[ + ("Authentication (9A)", 0x9A), + ("Signature (9C)", 0x9C), + ("Key Management (9D)", 0x9D), + ("Card Authentication (9E)", 0x9E), +]; + +fn default_mgm_hex() -> String { + hex::encode(piv::DEFAULT_MGM_KEY) +} + +fn parse_mgm(hex_str: &str) -> Option> { + let b = hex::decode(hex_str.trim()).ok()?; + matches!(b.len(), 16 | 24 | 32).then_some(b) +} + +/// Whether a management-key byte length matches its AES algorithm id. +fn piv_key_len_ok(algo: u8, len: usize) -> bool { + matches!((algo, len), (0x08, 16) | (0x0A, 24) | (0x0C, 32)) +} + +/// Resolve a management-auth dialog input to an [`MgmAuth`], or emit a validation +/// toast and return `None`. On a `--protect`'d card the field holds the PIN; else +/// a hex management key. `algo` is the card's read-back management-key algorithm. +fn resolve_mgm_auth( + input: &Entity, + protected: bool, + algo: u8, + view: &WeakEntity, + cx: &mut App, +) -> Option { + let text = input.read(cx).text().to_string(); + if protected { + let pin = text.trim().to_string(); + if pin.is_empty() { + let _ = view.update(cx, |_, cx| { + cx.emit(PivEvent::Notification( + "Enter the PIN to unlock the management key".into(), + )); + }); + return None; + } + return Some(MgmAuth::Pin(pin)); + } + match parse_mgm(&text) { + Some(key) => Some(MgmAuth::Key { key, algo }), + None => { + let _ = view.update(cx, |_, cx| { + cx.emit(PivEvent::Notification( + "Management key must be 16/24/32-byte hex".into(), + )); + }); + None + } + } +} + +pub struct PivViewModel { + pub(super) device: Entity, + pub(super) info: Option, + pub(super) loaded: bool, + pub(super) loading: bool, + _task: Option>, +} + +pub enum PivEvent { + Notification(String), +} + +impl EventEmitter for PivViewModel {} + +impl PivViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + this.on_device_event(cx); + }) + .detach(); + let mut this = Self { + device, + info: None, + loaded: false, + loading: false, + _task: None, + }; + this.load(cx); + this + } + + fn on_device_event(&mut self, cx: &mut Context) { + if self.device.read(cx).device_changed { + self.info = None; + self.loaded = false; + } + self.load(cx); + cx.notify(); + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + if repo.status.is_none() { + return AppletGate::Unsupported; + } + match repo.piv_features() { + None => AppletGate::Unsupported, + Some(_) if !repo.ccid_on() => AppletGate::CcidOff, + Some(_) if !repo.applet_enabled(USB_CAP_PIV) => AppletGate::Disabled("PIV"), + Some(_) => AppletGate::Ready, + } + } + + /// The stored management-key algorithm (default AES-192). + fn mgm_algo(&self) -> u8 { + self.info.as_ref().map(|i| i.mgm_algo).unwrap_or(piv::ALGO_AES192) + } + + /// Whether this card's management key is PIN-protected (ykman `--protect`). + fn mgm_protected(&self) -> bool { + self.info.as_ref().map(|i| i.mgm_protected).unwrap_or(false) + } + + /// The management-auth input for a dialog: a masked PIN field on a + /// `--protect`'d card, else a hex management-key field defaulting to the key. + fn mgm_input( + &self, + window: &mut Window, + cx: &mut App, + ) -> Entity { + if self.mgm_protected() { + cx.new(|cx| gpui_component::input::InputState::new(window, cx).masked(true)) + } else { + cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).default_value(default_mgm_hex()) + }) + } + } + + /// The dialog label for the management-auth field. + fn mgm_label(&self) -> &'static str { + if self.mgm_protected() { + "PIN (the management key is PIN-protected)" + } else { + "Management key (hex)" + } + } + + fn load(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::piv_read_info_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(info) => { + this.info = Some(info); + this.loaded = true; + } + Err(e) => { + log::warn!("PIV read failed: {e}"); + cx.emit(PivEvent::Notification(format!("PIV: {e}"))); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.load(cx); + } + + /// Run a blocking op, report on `status`, and reload on success. + fn run( + &mut self, + op: impl FnOnce() -> Result<(), PFError> + Send + 'static, + ok_msg: &'static str, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op() }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx)); + this.load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + // ── Generate ────────────────────────────────────────────────────────── + + pub(super) fn open_generate_dialog( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { + let algo_sel = select_state(window, cx, OPT_ALGO, 0); + let pin_sel = select_state(window, cx, OPT_PIN_POLICY, 0); + let touch_sel = select_state(window, cx, OPT_TOUCH_POLICY, 0); + let mgm = self.mgm_input(window, cx); + let mgm_algo = self.mgm_algo(); + let protected = self.mgm_protected(); + let mgm_label = self.mgm_label(); + + let view = cx.entity().downgrade(); + let submit = { + let algo_sel = algo_sel.clone(); + let pin_sel = pin_sel.clone(); + let touch_sel = touch_sel.clone(); + let mgm = mgm.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(auth) = resolve_mgm_auth(&mgm, protected, mgm_algo, &view, cx) else { + return; + }; + let algo = selected_key(&algo_sel, OPT_ALGO, cx); + let pin_pol = selected_key(&pin_sel, OPT_PIN_POLICY, cx); + let touch_pol = selected_key(&touch_sel, OPT_TOUCH_POLICY, cx); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Generating Key", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || { + DeviceRepo::piv_generate_blocking( + slot, algo, pin_pol, touch_pol, auth, + ) + .map(|_| ()) + }, + "Key generated.", + status, + cx, + ); + }); + }) + }; + + window.open_dialog(cx, move |dialog, _window, _| { + let algo_sel = algo_sel.clone(); + let pin_sel = pin_sel.clone(); + let touch_sel = touch_sel.clone(); + let mgm = mgm.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let field = |label: &str, sel: &Entity>>| { + gpui_component::v_flex().gap_1().flex_1().child(label.to_string()).child( + gpui_component::select::Select::new(sel).w_full().bg(rgb(0x222225)), + ) + }; + dialog + .title(format!("Generate — {}", piv::slot_label(slot))) + .child("Generates a new key pair and a self-signed certificate in this slot.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child(field("Algorithm", &algo_sel)) + .child( + gpui_component::h_flex() + .gap_3() + .child(field("PIN policy", &pin_sel)) + .child(field("Touch policy", &touch_sel)), + ) + .child(mgm_label) + .child(gpui_component::input::Input::new(&mgm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("gen") + .primary() + .label("Generate") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Export certificate (DER → PEM file) ───────────────────────────────── + + pub(super) fn open_export_cert(&mut self, slot: u8, _window: &mut Window, cx: &mut Context) { + let default_dir = std::env::var("HOME").map(std::path::PathBuf::from).unwrap_or_default(); + let receiver = + cx.prompt_for_new_path(&default_dir, Some(&format!("piv-{slot:02x}.pem"))); + let view = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(path))) = receiver.await else { + return; + }; + let der = cx + .background_executor() + .spawn(async move { DeviceRepo::piv_export_cert_blocking(slot) }) + .await; + let _ = view.update(cx, |_, cx| match der { + Ok(der) => { + let pem = der_to_pem(&der); + match std::fs::write(&path, pem) { + Ok(_) => cx.emit(PivEvent::Notification(format!( + "Certificate saved to {}", + path.display() + ))), + Err(e) => cx.emit(PivEvent::Notification(format!("Save failed: {e}"))), + } + } + Err(e) => cx.emit(PivEvent::Notification(format!("Export failed: {e}"))), + }); + })); + } + + // ── PIN / PUK ─────────────────────────────────────────────────────────── + + pub(super) fn open_change_pin(&mut self, is_puk: bool, window: &mut Window, cx: &mut Context) { + let title = if is_puk { "Change PUK" } else { "Change PIN" }; + self.two_secret_dialog( + title, + if is_puk { "Current PUK" } else { "Current PIN" }, + if is_puk { "New PUK" } else { "New PIN" }, + window, + cx, + move |cur, new| { + if is_puk { + DeviceRepo::piv_change_puk_blocking(cur, new) + } else { + DeviceRepo::piv_change_pin_blocking(cur, new) + } + }, + if is_puk { "PUK changed." } else { "PIN changed." }, + ); + } + + pub(super) fn open_unblock_pin(&mut self, window: &mut Window, cx: &mut Context) { + self.two_secret_dialog( + "Unblock PIN", + "PUK", + "New PIN", + window, + cx, + |puk, new| DeviceRepo::piv_unblock_pin_blocking(puk, new), + "PIN unblocked.", + ); + } + + /// A two-masked-field dialog (current/new or puk/new) running a blocking op. + fn two_secret_dialog( + &mut self, + title: &'static str, + label_a: &'static str, + label_b: &'static str, + window: &mut Window, + cx: &mut Context, + op: impl Fn(String, String) -> Result<(), PFError> + Send + Clone + 'static, + ok_msg: &'static str, + ) { + let a = cx.new(|cx| gpui_component::input::InputState::new(window, cx).masked(true)); + let b = cx.new(|cx| gpui_component::input::InputState::new(window, cx).masked(true)); + let view = cx.entity().downgrade(); + let submit = { + let a = a.clone(); + let b = b.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let av = a.read(cx).text().to_string(); + let bv = b.read(cx).text().to_string(); + if av.is_empty() || bv.is_empty() { + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog(title, window, cx); + let op = op.clone(); + let _ = view.update(cx, |this, cx| { + this.run(move || op(av, bv), ok_msg, status, cx); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let a = a.clone(); + let b = b.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(title) + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child(label_a) + .child(gpui_component::input::Input::new(&a)) + .child(label_b) + .child(gpui_component::input::Input::new(&b)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Save") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Delete certificate (mgmt-gated) ───────────────────────────────────── + + pub(super) fn open_delete_cert(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + let mgm_algo = self.mgm_algo(); + let mgm = self.mgm_input(window, cx); + let protected = self.mgm_protected(); + let mgm_label = self.mgm_label(); + let view = cx.entity().downgrade(); + let submit = { + let mgm = mgm.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(auth) = resolve_mgm_auth(&mgm, protected, mgm_algo, &view, cx) else { + return; + }; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Deleting Certificate", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || DeviceRepo::piv_delete_cert_blocking(slot, auth), + "Certificate deleted.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let mgm = mgm.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(format!("Delete certificate — {}", piv::slot_label(slot))) + .child("Clears this slot's certificate (the key stays). Requires the management key.") + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child(mgm_label) + .child(gpui_component::input::Input::new(&mgm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("del") + .danger() + .label("Delete") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Set PIN retries (mgmt + PIN) ───────────────────────────────────────── + + pub(super) fn open_set_retries(&mut self, window: &mut Window, cx: &mut Context) { + let mgm_algo = self.mgm_algo(); + let mgm = self.mgm_input(window, cx); + let protected = self.mgm_protected(); + let mgm_label = self.mgm_label(); + let pin = cx.new(|cx| gpui_component::input::InputState::new(window, cx).masked(true)); + let pin_tries = select_state(window, cx, OPT_TRIES, 0); + let puk_tries = select_state(window, cx, OPT_TRIES, 0); + let view = cx.entity().downgrade(); + let submit = { + let mgm = mgm.clone(); + let pin = pin.clone(); + let pin_tries = pin_tries.clone(); + let puk_tries = puk_tries.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(auth) = resolve_mgm_auth(&mgm, protected, mgm_algo, &view, cx) else { + return; + }; + let pin_v = pin.read(cx).text().to_string(); + if pin_v.is_empty() { + return; + } + let pt = selected_key(&pin_tries, OPT_TRIES, cx); + let ut = selected_key(&puk_tries, OPT_TRIES, cx); + window.close_dialog(cx); + let status = dialog::open_status_dialog("Setting Retries", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || DeviceRepo::piv_set_retries_blocking(auth, pin_v, pt, ut), + "Retry counters updated (PIN/PUK reset to defaults).", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let mgm = mgm.clone(); + let pin = pin.clone(); + let pin_tries = pin_tries.clone(); + let puk_tries = puk_tries.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let field = |label: &str, sel: &Entity>>| { + gpui_component::v_flex().gap_1().flex_1().child(label.to_string()).child( + gpui_component::select::Select::new(sel).w_full().bg(rgb(0x222225)), + ) + }; + dialog + .title("Set PIN Retries") + .child("Resets the PIN and PUK to their defaults and sets new retry limits.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child( + gpui_component::h_flex() + .gap_3() + .child(field("PIN retries", &pin_tries)) + .child(field("PUK retries", &puk_tries)), + ) + .child("Current PIN") + .child(gpui_component::input::Input::new(&pin)) + .child(mgm_label) + .child(gpui_component::input::Input::new(&mgm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Apply") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Change management key (mgmt-gated) ─────────────────────────────────── + + pub(super) fn open_change_mgm(&mut self, window: &mut Window, cx: &mut Context) { + let cur_algo = self.mgm_algo(); + let protected = self.mgm_protected(); + let cur_label = if protected { + "PIN (unlocks the current management key)" + } else { + "Current management key (hex)" + }; + let cur = self.mgm_input(window, cx); + let new = cx.new(|cx| gpui_component::input::InputState::new(window, cx)); + let algo_sel = select_state(window, cx, OPT_MGM_ALGO, 0); + let touch_sel = select_state(window, cx, OPT_MGM_TOUCH, 0); + let view = cx.entity().downgrade(); + + let gen_key = { + let new = new.clone(); + let algo_sel = algo_sel.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let algo = selected_key(&algo_sel, OPT_MGM_ALGO, cx); + if let Ok(k) = piv::random_key(algo) { + new.update(cx, |st, cx| st.set_value(hex::encode(k), window, cx)); + } + }) + }; + let submit = { + let cur = cur.clone(); + let new = new.clone(); + let algo_sel = algo_sel.clone(); + let touch_sel = touch_sel.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let notify = |cx: &mut App, msg: &str| { + let _ = view.update(cx, |_, cx| cx.emit(PivEvent::Notification(msg.to_string()))); + }; + let Some(current) = resolve_mgm_auth(&cur, protected, cur_algo, &view, cx) else { + return; + }; + let new_algo = selected_key(&algo_sel, OPT_MGM_ALGO, cx); + let new_key = match hex::decode(new.read(cx).text().to_string().trim()) { + Ok(k) if piv_key_len_ok(new_algo, k.len()) => k, + _ => return notify(cx, "New key length must match the algorithm (16/24/32 bytes)"), + }; + let touch = selected_key(&touch_sel, OPT_MGM_TOUCH, cx) == 1; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Changing Management Key", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || { + DeviceRepo::piv_set_mgm_blocking(current, new_algo, new_key, touch) + }, + "Management key changed.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let cur = cur.clone(); + let new = new.clone(); + let algo_sel = algo_sel.clone(); + let touch_sel = touch_sel.clone(); + let gen_key = gen_key.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + let field = |label: &str, sel: &Entity>>| { + gpui_component::v_flex().gap_1().flex_1().child(label.to_string()).child( + gpui_component::select::Select::new(sel).w_full().bg(rgb(0x222225)), + ) + }; + dialog + .title("Change Management Key") + .child("Sets a new PIV management key. Store it safely — it is required for all key/cert changes.") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child(cur_label) + .child(gpui_component::input::Input::new(&cur)) + .child( + gpui_component::h_flex() + .gap_3() + .child(field("New algorithm", &algo_sel)) + .child(field("Touch", &touch_sel)), + ) + .child("New key (hex)") + .child( + gpui_component::h_flex() + .gap_2() + .items_center() + .child( + gpui_component::v_flex() + .flex_1() + .child(gpui_component::input::Input::new(&new)), + ) + .child( + gpui_component::button::Button::new("gen-mgm") + .label("Generate") + .outline() + .on_click(move |_, window, cx| gen_key(window, cx)), + ), + ), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Change") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Import certificate / key (file → management-key dialog) ────────────── + + pub(super) fn open_import_cert(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + let handle = window.window_handle(); + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select certificate (PEM or DER)".into()), + }); + let view = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let Ok(bytes) = std::fs::read(&path) else { + let _ = view.update(cx, |_, cx| cx.emit(PivEvent::Notification("Could not read file".into()))); + return; + }; + let Some(der) = cert_pem_to_der(&bytes) else { + let _ = view.update(cx, |_, cx| { + cx.emit(PivEvent::Notification("Not a valid PEM/DER certificate".into())) + }); + return; + }; + let _ = cx.update_window(handle, |_, window, cx| { + let _ = view.update(cx, |this, cx| this.open_mgm_import(slot, der, false, window, cx)); + }); + })); + } + + pub(super) fn open_import_key(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + let handle = window.window_handle(); + let receiver = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select private key (PEM or DER)".into()), + }); + let view = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let Ok(bytes) = std::fs::read(&path) else { + let _ = view.update(cx, |_, cx| cx.emit(PivEvent::Notification("Could not read file".into()))); + return; + }; + let _ = cx.update_window(handle, |_, window, cx| { + let _ = view.update(cx, |this, cx| this.open_mgm_import(slot, bytes, true, window, cx)); + }); + })); + } + + fn open_mgm_import( + &mut self, + slot: u8, + file: Vec, + is_key: bool, + window: &mut Window, + cx: &mut Context, + ) { + let mgm_algo = self.mgm_algo(); + let mgm = self.mgm_input(window, cx); + let protected = self.mgm_protected(); + let mgm_label = self.mgm_label(); + let view = cx.entity().downgrade(); + let submit = { + let mgm = mgm.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(auth) = resolve_mgm_auth(&mgm, protected, mgm_algo, &view, cx) else { + return; + }; + let file = file.clone(); + window.close_dialog(cx); + let status = dialog::open_status_dialog( + if is_key { "Importing Key" } else { "Importing Certificate" }, + window, + cx, + ); + let _ = view.update(cx, |this, cx| { + if is_key { + this.run( + move || DeviceRepo::piv_import_key_blocking(slot, file, auth), + "Key imported.", + status, + cx, + ); + } else { + this.run( + move || DeviceRepo::piv_import_cert_blocking(slot, file, auth), + "Certificate imported.", + status, + cx, + ); + } + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let mgm = mgm.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(if is_key { "Import Key" } else { "Import Certificate" }) + .child("Enter the management key to authorise the import.") + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child(mgm_label) + .child(gpui_component::input::Input::new(&mgm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("ok") + .primary() + .label("Import") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Attestation (export the attestation cert of a generated key) ───────── + + pub(super) fn open_attest(&mut self, slot: u8, _window: &mut Window, cx: &mut Context) { + let default_dir = std::env::var("HOME").map(std::path::PathBuf::from).unwrap_or_default(); + let receiver = + cx.prompt_for_new_path(&default_dir, Some(&format!("piv-{slot:02x}-attestation.pem"))); + let view = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(path))) = receiver.await else { + return; + }; + let der = cx + .background_executor() + .spawn(async move { DeviceRepo::piv_attest_blocking(slot) }) + .await; + let _ = view.update(cx, |_, cx| match der { + Ok(der) => match std::fs::write(&path, der_to_pem(&der)) { + Ok(_) => cx.emit(PivEvent::Notification(format!( + "Attestation saved to {}", + path.display() + ))), + Err(e) => cx.emit(PivEvent::Notification(format!("Save failed: {e}"))), + }, + Err(e) => cx.emit(PivEvent::Notification(format!("Attestation failed: {e}"))), + }); + })); + } + + // ── Delete key (mgmt-gated) ────────────────────────────────────────────── + + pub(super) fn open_delete_key(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + let mgm_algo = self.mgm_algo(); + let mgm = self.mgm_input(window, cx); + let protected = self.mgm_protected(); + let mgm_label = self.mgm_label(); + let view = cx.entity().downgrade(); + let submit = { + let mgm = mgm.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(auth) = resolve_mgm_auth(&mgm, protected, mgm_algo, &view, cx) else { + return; + }; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Deleting Key", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || DeviceRepo::piv_delete_key_blocking(slot, auth), + "Key deleted.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let mgm = mgm.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(format!("Delete key — {}", piv::slot_label(slot))) + .child("Permanently deletes this slot's key and certificate. Requires the management key.") + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child(mgm_label) + .child(gpui_component::input::Input::new(&mgm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("del") + .danger() + .label("Delete") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Move key (mgmt-gated) ──────────────────────────────────────────────── + + pub(super) fn open_move_key(&mut self, src: u8, window: &mut Window, cx: &mut Context) { + let mgm_algo = self.mgm_algo(); + let protected = self.mgm_protected(); + let mgm_label = self.mgm_label(); + let dst_sel = select_state(window, cx, OPT_SLOTS, 0); + let mgm = self.mgm_input(window, cx); + let view = cx.entity().downgrade(); + let submit = { + let dst_sel = dst_sel.clone(); + let mgm = mgm.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(auth) = resolve_mgm_auth(&mgm, protected, mgm_algo, &view, cx) else { + return; + }; + let dst = selected_key(&dst_sel, OPT_SLOTS, cx); + if dst == src { + let _ = view.update(cx, |_, cx| { + cx.emit(PivEvent::Notification("Choose a different destination slot".into())); + }); + return; + } + window.close_dialog(cx); + let status = dialog::open_status_dialog("Moving Key", window, cx); + let _ = view.update(cx, |this, cx| { + this.run( + move || DeviceRepo::piv_move_key_blocking(src, dst, auth), + "Key moved.", + status, + cx, + ); + }); + }) + }; + window.open_dialog(cx, move |dialog, _w, _| { + let dst_sel = dst_sel.clone(); + let mgm = mgm.clone(); + let ok = submit.clone(); + let btn = submit.clone(); + dialog + .title(format!("Move key from {}", piv::slot_label(src))) + .child("Moves the key + certificate to another slot (overwrites the destination).") + .child( + gpui_component::v_flex() + .gap_3() + .pb_2() + .child("Destination slot") + .child( + gpui_component::select::Select::new(&dst_sel) + .w_full() + .bg(rgb(0x222225)), + ) + .child(mgm_label) + .child(gpui_component::input::Input::new(&mgm)), + ) + .on_ok(move |_, window, cx| { + ok(window, cx); + false + }) + .footer(move |_, _w, _c, _| { + let s = btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("mv") + .primary() + .label("Move") + .on_click(move |_, window, cx| s(window, cx)), + ] + }) + }); + } + + // ── Reset ──────────────────────────────────────────────────────────────── + + pub(super) fn open_reset_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view = cx.entity().downgrade(); + dialog::open_confirm( + "Reset PIV Applet", + "This blocks the PIN and PUK, then factory-resets PIV — deleting ALL keys and certificates and restoring the default PIN/PUK/management key. This cannot be undone.".to_string(), + "Reset", + gpui_component::button::ButtonVariant::Danger, + window, + cx, + move |_dh, window, cx| { + window.close_dialog(cx); + let status = dialog::open_status_dialog("Resetting PIV", window, cx); + let _ = view.update(cx, |this, cx| { + this.run(DeviceRepo::piv_reset_blocking, "PIV applet reset.", status, cx); + }); + }, + ); + } +} + +/// Decode a certificate from PEM or accept raw DER. +fn cert_pem_to_der(input: &[u8]) -> Option> { + let text = std::str::from_utf8(input).unwrap_or(""); + if let Some(begin) = text.find("-----BEGIN CERTIFICATE-----") { + let body = &text[begin + 27..]; + let end = body.find("-----END")?; + let b64: String = body[..end].chars().filter(|c| !c.is_whitespace()).collect(); + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()).ok() + } else if input.first() == Some(&0x30) { + Some(input.to_vec()) + } else { + None + } +} + +/// Wrap a DER certificate as PEM. +fn der_to_pem(der: &[u8]) -> String { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + let mut out = String::from("-----BEGIN CERTIFICATE-----\n"); + for chunk in b64.as_bytes().chunks(64) { + out.push_str(std::str::from_utf8(chunk).unwrap_or("")); + out.push('\n'); + } + out.push_str("-----END CERTIFICATE-----\n"); + out +} diff --git a/src/ui/screens/slots/mod.rs b/src/ui/screens/slots/mod.rs new file mode 100644 index 0000000..7aa17c7 --- /dev/null +++ b/src/ui/screens/slots/mod.rs @@ -0,0 +1,4 @@ +pub mod program_form; +pub mod view; +pub mod view_model; +pub use view_model::{SlotsEvent, SlotsViewModel}; diff --git a/src/ui/screens/slots/program_form.rs b/src/ui/screens/slots/program_form.rs new file mode 100644 index 0000000..14f4dc4 --- /dev/null +++ b/src/ui/screens/slots/program_form.rs @@ -0,0 +1,428 @@ +//! The slot-programming dialog — a reactive form whose fields change with the +//! selected credential type, mirroring Yubico Authenticator's OTP editor. +//! +//! Each type shows only its own inputs and options: challenge-response has a +//! secret + touch; OATH-HOTP a secret + digits + append; static a password; +//! Yubico OTP the public/private/secret triple. A subscription to the type +//! dropdown re-renders the form on change. The slot access code is optional and +//! shown for every type. + +use crate::error::PFError; +use crate::ui::components::dialog; +use crate::ui::components::form::{select_state, selected_key, LabeledU8}; +use crate::ui::models::device::{otp, DeviceRepo}; +use crate::ui::screens::slots::view_model::{SlotsEvent, SlotsViewModel}; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputState}; +use gpui_component::select::{Select, SelectEvent, SelectState}; +use gpui_component::{h_flex, v_flex, WindowExt}; + +const OPT_TYPE: &[(&str, u8)] = &[ + ("Challenge-response", 0), + ("OATH-HOTP", 1), + ("Static password", 2), + ("Yubico OTP", 3), +]; +const OPT_TOUCH: &[(&str, u8)] = &[("Not required", 0), ("Touch required", 1)]; +const OPT_DIGITS: &[(&str, u8)] = &[("6 digits", 6), ("8 digits", 8)]; +const OPT_APPEND: &[(&str, u8)] = &[("No", 0), ("Append Enter", 1)]; + +/// Parse an optional hex access code into a fixed 6-byte code (zeros if empty). +pub(super) fn parse_acc(hex_str: &str) -> Option<[u8; 6]> { + let t = hex_str.trim(); + if t.is_empty() { + return Some([0u8; 6]); + } + let bytes = hex::decode(t).ok()?; + if bytes.len() > 6 { + return None; + } + let mut acc = [0u8; 6]; + acc[..bytes.len()].copy_from_slice(&bytes); + Some(acc) +} + +/// A reactive slot-programming form embedded in the dialog. Owns every input and +/// dropdown; re-renders when the credential type changes. +pub struct ProgramSlotForm { + slot: u8, + view: WeakEntity, + type_sel: Entity>>, + touch_sel: Entity>>, + digits_sel: Entity>>, + append_sel: Entity>>, + secret: Entity, + password: Entity, + yk_public: Entity, + yk_private: Entity, + yk_key: Entity, + new_acc: Entity, + cur_acc: Entity, + _sub: Subscription, +} + +/// Build the form and open the programming dialog for `slot`. +pub(super) fn open(slot: u8, window: &mut Window, cx: &mut Context) { + let type_sel = select_state(window, cx, OPT_TYPE, 0); + let touch_sel = select_state(window, cx, OPT_TOUCH, 0); + let digits_sel = select_state(window, cx, OPT_DIGITS, 0); + let append_sel = select_state(window, cx, OPT_APPEND, 0); + let input = |ph: &str, window: &mut Window, cx: &mut Context| { + let ph = ph.to_string(); + cx.new(|cx| InputState::new(window, cx).placeholder(ph)) + }; + let secret = input("Secret key (hex)", window, cx); + let password = input("Password to type (ASCII)", window, cx); + let yk_public = input("Public ID (modhex)", window, cx); + let yk_private = input("Private ID (hex)", window, cx); + let yk_key = input("Secret key (hex)", window, cx); + let new_acc = input("Set an access code (hex, optional)", window, cx); + let cur_acc = input("Current access code, if protected", window, cx); + let view = cx.entity().downgrade(); + + let form = cx.new(|cx| { + let sub = cx.subscribe( + &type_sel, + |_this: &mut ProgramSlotForm, _, _e: &SelectEvent>, cx| cx.notify(), + ); + ProgramSlotForm { + slot, + view, + type_sel, + touch_sel, + digits_sel, + append_sel, + secret, + password, + yk_public, + yk_private, + yk_key, + new_acc, + cur_acc, + _sub: sub, + } + }); + + window.open_dialog(cx, move |dialog, _w, _| { + let body = form.clone(); + let footer_form = form.clone(); + dialog + .title(format!("Program Slot {slot}")) + .child(body) + .footer(move |_, _w, _c, _| { + let f = footer_form.clone(); + vec![ + Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + Button::new("program").primary().label("Program").on_click( + move |_, window, cx| { + let f = f.clone(); + f.update(cx, |f, cx| f.submit(window, cx)); + }, + ), + ] + }) + }); +} + +impl ProgramSlotForm { + fn notify(&self, cx: &mut Context, msg: &str) { + let _ = self + .view + .update(cx, |_, cx| cx.emit(SlotsEvent::Notification(msg.to_string()))); + } + + /// Close the form dialog, show a status dialog, and run the program op. + fn dispatch( + &self, + window: &mut Window, + cx: &mut Context, + op: impl FnOnce() -> Result<(), PFError> + Send + 'static, + msg: String, + ) { + window.close_dialog(cx); + let status = dialog::open_status_dialog("Programming Slot", window, cx); + let _ = self + .view + .update(cx, |vm, cx| vm.execute_program(op, msg, status, cx)); + } + + fn submit(&self, window: &mut Window, cx: &mut Context) { + let ty = selected_key(&self.type_sel, OPT_TYPE, cx); + let append_cr = selected_key(&self.append_sel, OPT_APPEND, cx) == 1; + let touch = selected_key(&self.touch_sel, OPT_TOUCH, cx) == 1; + let digits8 = selected_key(&self.digits_sel, OPT_DIGITS, cx) == 8; + + let (new_a, cur_a) = match ( + parse_acc(&self.new_acc.read(cx).text().to_string()), + parse_acc(&self.cur_acc.read(cx).text().to_string()), + ) { + (Some(n), Some(c)) => (n, c), + _ => return self.notify(cx, "Access codes must be hex, ≤ 6 bytes"), + }; + let slot = self.slot; + let hex_secret = |s: &Entity, cx: &mut Context| { + match hex::decode(s.read(cx).text().to_string().trim()) { + Ok(b) if !b.is_empty() && b.len() <= 20 => Some(b), + _ => None, + } + }; + + match ty { + 0 => { + let Some(bytes) = hex_secret(&self.secret, cx) else { + return self.notify(cx, "Secret must be 1–20 bytes of hex"); + }; + self.dispatch( + window, + cx, + move || DeviceRepo::otp_program_chalresp_blocking(slot, bytes, touch, new_a, cur_a), + "Challenge-response programmed.".into(), + ); + } + 1 => { + let Some(bytes) = hex_secret(&self.secret, cx) else { + return self.notify(cx, "Secret must be 1–20 bytes of hex"); + }; + self.dispatch( + window, + cx, + move || { + DeviceRepo::otp_program_hotp_blocking( + slot, bytes, digits8, append_cr, new_a, cur_a, + ) + }, + "OATH-HOTP programmed.".into(), + ); + } + 2 => { + let scancodes = + match otp::ascii_to_scancodes(self.password.read(cx).text().to_string().trim()) { + Some(s) if !s.is_empty() => s, + _ => return self.notify(cx, "Password must be ASCII, 1–38 characters"), + }; + self.dispatch( + window, + cx, + move || { + DeviceRepo::otp_program_static_blocking( + slot, scancodes, append_cr, new_a, cur_a, + ) + }, + "Static password programmed.".into(), + ); + } + _ => { + let public = + match otp::modhex_decode(self.yk_public.read(cx).text().to_string().trim()) { + Some(p) if !p.is_empty() && p.len() <= 16 => p, + _ => return self.notify(cx, "Public ID must be modhex (≤ 16 bytes) — use Generate"), + }; + let private: [u8; 6] = + match hex::decode(self.yk_private.read(cx).text().to_string().trim()) { + Ok(b) if b.len() == 6 => b.try_into().unwrap(), + _ => return self.notify(cx, "Private ID must be 6 bytes of hex — use Generate"), + }; + let key: [u8; 16] = + match hex::decode(self.yk_key.read(cx).text().to_string().trim()) { + Ok(b) if b.len() == 16 => b.try_into().unwrap(), + _ => return self.notify(cx, "Secret key must be 16 bytes of hex — use Generate"), + }; + let msg = format!( + "Yubico OTP programmed. Register with a validation server:\nPublic ID: {}\nPrivate ID: {}\nKey: {}", + otp::modhex_encode(&public), + hex::encode(private), + hex::encode(key), + ); + self.dispatch( + window, + cx, + move || { + DeviceRepo::otp_program_yubico_blocking( + slot, public, private, key, append_cr, new_a, cur_a, + ) + }, + msg, + ); + } + } + } + + /// A labelled input with a Generate button that runs `on_gen`. + fn input_with_generate( + &self, + label: &str, + input: &Entity, + gen_id: &'static str, + on_gen: impl Fn(&mut Window, &mut App) + 'static, + cx: &mut Context, + ) -> AnyElement { + v_flex() + .gap_1() + .child(label.to_string()) + .child( + h_flex() + .gap_2() + .items_center() + .child(v_flex().flex_1().child(Input::new(input))) + .child( + Button::new(gen_id) + .label("Generate") + .outline() + .on_click(cx.listener(move |_, _, window, cx| on_gen(window, cx))), + ), + ) + .into_any_element() + } +} + +fn labeled_select(label: &str, sel: &Entity>>) -> AnyElement { + v_flex() + .gap_1() + .flex_1() + .child(label.to_string()) + .child(Select::new(sel).w_full().bg(rgb(0x222225))) + .into_any_element() +} + +fn labeled_input(label: &str, input: &Entity) -> AnyElement { + v_flex() + .gap_1() + .flex_1() + .child(label.to_string()) + .child(Input::new(input)) + .into_any_element() +} + +impl Render for ProgramSlotForm { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ty = selected_key(&self.type_sel, OPT_TYPE, cx); + + // Fields specific to the selected credential type. + let type_fields = match ty { + 0 => v_flex() + .gap_3() + .child(self.input_with_generate( + "Secret key (hex, ≤ 20 bytes)", + &self.secret, + "gen-secret", + { + let secret = self.secret.clone(); + move |window, cx| { + if let Ok(s) = otp::random_secret() { + secret.update(cx, |st, cx| st.set_value(hex::encode(s), window, cx)); + } + } + }, + cx, + )) + .child(labeled_select("Touch", &self.touch_sel)) + .into_any_element(), + 1 => v_flex() + .gap_3() + .child(self.input_with_generate( + "Secret key (hex, ≤ 20 bytes)", + &self.secret, + "gen-secret", + { + let secret = self.secret.clone(); + move |window, cx| { + if let Ok(s) = otp::random_secret() { + secret.update(cx, |st, cx| st.set_value(hex::encode(s), window, cx)); + } + } + }, + cx, + )) + .child( + h_flex() + .gap_3() + .child(labeled_select("Digits", &self.digits_sel)) + .child(labeled_select("Append Enter", &self.append_sel)), + ) + .into_any_element(), + 2 => v_flex() + .gap_3() + .child(self.input_with_generate( + "Password (ASCII, 1–38 characters)", + &self.password, + "gen-password", + { + let password = self.password.clone(); + move |window, cx| { + if let Ok(s) = otp::random_secret() { + password.update(cx, |st, cx| { + st.set_value(otp::modhex_encode(&s[..8]), window, cx) + }); + } + } + }, + cx, + )) + .child(labeled_select("Append Enter", &self.append_sel)) + .into_any_element(), + _ => v_flex() + .gap_3() + .child(labeled_input("Public ID (modhex)", &self.yk_public)) + .child(labeled_input("Private ID (hex, 6 bytes)", &self.yk_private)) + .child(labeled_input("Secret key (hex, 16 bytes)", &self.yk_key)) + .child( + h_flex().justify_between().items_center().child(labeled_select( + "Append Enter", + &self.append_sel, + )), + ) + .child( + Button::new("gen-yubico") + .label("Generate keys") + .outline() + .on_click(cx.listener({ + let (pubf, privf, keyf) = ( + self.yk_public.clone(), + self.yk_private.clone(), + self.yk_key.clone(), + ); + move |_, _, window, cx| { + if let Ok((p, pv, k)) = otp::random_yubico() { + pubf.update(cx, |st, cx| { + st.set_value(otp::modhex_encode(&p), window, cx) + }); + privf.update(cx, |st, cx| { + st.set_value(hex::encode(pv), window, cx) + }); + keyf.update(cx, |st, cx| { + st.set_value(hex::encode(k), window, cx) + }); + } + } + })), + ) + .into_any_element(), + }; + + v_flex() + .gap_3() + .pb_2() + .child(labeled_select("Credential type", &self.type_sel)) + .child(type_fields) + .child( + v_flex() + .gap_1() + .pt_2() + .child( + div() + .text_xs() + .text_color(rgb(0x8b8b8f)) + .child("Slot access code (optional)"), + ) + .child( + h_flex() + .gap_3() + .child(labeled_input("Set new code", &self.new_acc)) + .child(labeled_input("Current code", &self.cur_acc)), + ), + ) + } +} diff --git a/src/ui/screens/slots/view.rs b/src/ui/screens/slots/view.rs new file mode 100644 index 0000000..4ad302d --- /dev/null +++ b/src/ui/screens/slots/view.rs @@ -0,0 +1,147 @@ +//! Slots (OTP) screen rendering. + +use crate::ui::components::card::Card; +use crate::ui::components::page_view::PageView; +use crate::ui::models::device::otp; +use crate::ui::screens::slots::view_model::SlotsViewModel; +use gpui::*; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; + +fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { + v_flex() + .items_center() + .justify_center() + .h_64() + .gap_2() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child(div().font_semibold().child(heading.to_string())) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) + .into_any_element() +} + +impl SlotsViewModel { + fn render_slot_card(&self, slot: u8, cx: &mut Context) -> AnyElement { + let info = self.slot_info(slot); + let configured = info.configured(); + let theme = cx.theme(); + + let status_text = if configured { + let mut s = info.kind.label().to_string(); + if info.touch { + s.push_str(" · touch"); + } + s + } else { + "Empty".to_string() + }; + + let program_btn = Button::new(SharedString::from(format!("prog-{slot}"))) + .label(if configured { "Reprogram" } else { "Program" }) + .outline() + .disabled(self.loading) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_program_dialog(slot, window, cx); + })); + let test_btn = (info.kind == otp::SlotType::ChallengeResponse).then(|| { + Button::new(SharedString::from(format!("test-{slot}"))) + .label("Test") + .ghost() + .disabled(self.loading) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_test_dialog(slot, window, cx); + })) + }); + let delete_btn = configured.then(|| { + Button::new(SharedString::from(format!("del-{slot}"))) + .icon(Icon::default().path("icons/trash-2.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_delete_dialog(slot, window, cx); + })) + }); + + h_flex() + .justify_between() + .items_center() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .gap_0p5() + .child(div().font_medium().child(format!("Slot {slot}"))) + .child( + div() + .text_sm() + .text_color(if configured { + theme.foreground + } else { + theme.muted_foreground + }) + .child(status_text), + ), + ) + .child( + h_flex() + .gap_2() + .child(program_btn) + .children(test_btn) + .children(delete_btn), + ) + .into_any_element() + } +} + +impl Render for SlotsViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const TITLE: &str = "Slots"; + const SUBTITLE: &str = "Configurable OTP slots (Yubico OTP protocol)."; + + if let Some((heading, body)) = self.gate(cx).message() { + let theme = cx.theme(); + return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme) + .into_any_element(); + } + + let count = self.slot_count(cx); + let mut cards = Vec::with_capacity(count as usize); + for slot in 1..=count { + cards.push(self.render_slot_card(slot, cx)); + } + + let swap_btn = Button::new("swap-slots") + .label("Swap 1 ↔ 2") + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| this.open_swap_dialog(window, cx))); + let refresh_btn = Button::new("refresh-slots") + .icon(Icon::default().path("icons/refresh-cw.svg")) + .ghost() + .disabled(self.loading) + .on_click(cx.listener(|this, _, _, cx| this.refresh(cx))); + + let theme = cx.theme(); + let toolbar = h_flex().gap_2().child(swap_btn).child(refresh_btn); + + let slots_card = Card::new() + .title("Slots") + .description(format!("{count} configurable slots")) + .icon(Icon::default().path("icons/asterisk.svg")) + .header_right(toolbar) + .child(v_flex().gap_2().children(cards)); + + let content = v_flex().gap_6().child(slots_card); + PageView::build(TITLE, SUBTITLE, content, theme).into_any_element() + } +} diff --git a/src/ui/screens/slots/view_model.rs b/src/ui/screens/slots/view_model.rs new file mode 100644 index 0000000..1108074 --- /dev/null +++ b/src/ui/screens/slots/view_model.rs @@ -0,0 +1,450 @@ +//! View model for the Slots (OTP) screen — the configurable YubiKey slots. + +use crate::error::PFError; +use crate::ui::app::AppModels; +use crate::ui::components::applet_gate::AppletGate; +use crate::ui::components::dialog; +use crate::ui::components::dialog::StatusContent; +use crate::ui::models::device::{otp, DeviceEvent, DeviceRepo, USB_CAP_OTP}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::WindowExt; + +/// Slots screen state and OTP slot operations. +pub struct SlotsViewModel { + pub(super) device: Entity, + pub(super) slots: Vec, + pub(super) loaded: bool, + pub(super) loading: bool, + _task: Option>, +} + +pub enum SlotsEvent { + Notification(String), +} + +impl EventEmitter for SlotsViewModel {} + +/// Read the delete/swap dialog's access-code input, or emit a validation toast +/// and return `None`. An empty field is a valid all-zero code (unprotected slot). +fn current_acc( + input: &Entity, + view: &WeakEntity, + cx: &mut App, +) -> Option<[u8; 6]> { + match super::program_form::parse_acc(&input.read(cx).text().to_string()) { + Some(acc) => Some(acc), + None => { + let _ = view.update(cx, |_, cx| { + cx.emit(SlotsEvent::Notification( + "Access code must be empty or up to 12 hex chars (6 bytes)".into(), + )); + }); + None + } + } +} + +impl SlotsViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| { + this.on_device_event(cx); + }) + .detach(); + let mut this = Self { + device, + slots: Vec::new(), + loaded: false, + loading: false, + _task: None, + }; + this.load(cx); + this + } + + fn on_device_event(&mut self, cx: &mut Context) { + if self.device.read(cx).device_changed { + self.slots.clear(); + self.loaded = false; + } + self.load(cx); + cx.notify(); + } + + pub(super) fn gate(&self, cx: &App) -> AppletGate { + let repo = self.device.read(cx); + if repo.status.is_none() { + return AppletGate::Unsupported; + } + match repo.otp_features() { + None => AppletGate::Unsupported, + Some(_) if !repo.ccid_on() => AppletGate::CcidOff, + Some(_) if !repo.applet_enabled(USB_CAP_OTP) => AppletGate::Disabled("OTP"), + Some(_) => AppletGate::Ready, + } + } + + /// Number of slots the firmware exposes (2 classic, 4 for RS-Key). + pub(super) fn slot_count(&self, cx: &App) -> u8 { + self.device + .read(cx) + .otp_features() + .map(|f| f.slots) + .unwrap_or(2) + } + + pub(super) fn slot_info(&self, slot: u8) -> otp::SlotInfo { + self.slots + .iter() + .find(|s| s.slot == slot) + .copied() + .unwrap_or(otp::SlotInfo { + slot, + kind: otp::SlotType::Empty, + touch: false, + }) + } + + fn load(&mut self, cx: &mut Context) { + if self.loading || self.gate(cx) != AppletGate::Ready { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async { DeviceRepo::otp_read_info_blocking() }) + .await; + let _ = weak.update(cx, |this, cx| this.apply(res, cx)); + })); + } + + fn apply(&mut self, res: Result<[otp::SlotInfo; 4], PFError>, cx: &mut Context) { + self.loading = false; + match res { + Ok(slots) => { + self.slots = slots.to_vec(); + self.loaded = true; + } + Err(e) => { + log::warn!("OTP status read failed: {e}"); + cx.emit(SlotsEvent::Notification(format!("Slots: {e}"))); + } + } + cx.notify(); + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.load(cx); + } + + pub(super) fn open_swap_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let acc_input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Access code (hex, if a slot is protected)") + }); + let view = cx.entity().downgrade(); + let submit = { + let acc_input = acc_input.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(acc) = current_acc(&acc_input, &view, cx) else { return }; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Swap Slots", window, cx); + let _ = view.update(cx, |this, cx| this.execute_swap(acc, status, cx)); + }) + }; + window.open_dialog(cx, move |dialog, _window, _| { + let acc_input = acc_input.clone(); + let submit_ok = submit.clone(); + let submit_btn = submit.clone(); + dialog + .title("Swap Slots") + .child("Swap the contents of slot 1 and slot 2?") + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child("Access code (hex, leave empty if unprotected)") + .child(gpui_component::input::Input::new(&acc_input)), + ) + .on_ok(move |_, window, cx| { + submit_ok(window, cx); + false + }) + .footer(move |_, _window, _cx, _| { + let submit = submit_btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("swap") + .primary() + .label("Swap") + .on_click(move |_, window, cx| submit(window, cx)), + ] + }) + }); + } + + fn execute_swap( + &mut self, + acc: [u8; 6], + status: WeakEntity, + cx: &mut Context, + ) { + self.run_op( + cx, + move |_| DeviceRepo::otp_swap_blocking(acc), + "Slots swapped.", + move |cx, msg, ok| { + let _ = status.update(cx, |d, cx| { + if ok { + d.set_success(msg, cx) + } else { + d.set_error(msg, cx) + } + }); + }, + ); + } + + pub(super) fn open_delete_dialog(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + let acc_input = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Access code (hex, if the slot is protected)") + }); + let view = cx.entity().downgrade(); + let submit = { + let acc_input = acc_input.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let Some(acc) = current_acc(&acc_input, &view, cx) else { return }; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Delete Slot", window, cx); + let _ = view.update(cx, |this, cx| this.execute_delete(slot, acc, status, cx)); + }) + }; + window.open_dialog(cx, move |dialog, _window, _| { + let acc_input = acc_input.clone(); + let submit_ok = submit.clone(); + let submit_btn = submit.clone(); + dialog + .title(format!("Delete Slot {slot}")) + .child(format!( + "Erase the configuration in slot {slot}? This cannot be undone." + )) + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child("Access code (hex, leave empty if unprotected)") + .child(gpui_component::input::Input::new(&acc_input)), + ) + .on_ok(move |_, window, cx| { + submit_ok(window, cx); + false + }) + .footer(move |_, _window, _cx, _| { + let submit = submit_btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("delete") + .danger() + .label("Delete") + .on_click(move |_, window, cx| submit(window, cx)), + ] + }) + }); + } + + fn execute_delete( + &mut self, + slot: u8, + acc: [u8; 6], + status: WeakEntity, + cx: &mut Context, + ) { + self.run_op( + cx, + move |_| DeviceRepo::otp_delete_blocking(slot, acc), + "Slot deleted.", + move |cx, msg, ok| { + let _ = status.update(cx, |d, cx| { + if ok { + d.set_success(msg, cx) + } else { + d.set_error(msg, cx) + } + }); + }, + ); + } + + pub(super) fn open_program_dialog( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { + super::program_form::open(slot, window, cx); + } + + pub(super) fn execute_program( + &mut self, + op: impl FnOnce() -> Result<(), PFError> + Send + 'static, + ok_msg: String, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op() }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + let _ = status.update(cx, |d, cx| d.set_success(ok_msg, cx)); + this.load(cx); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn open_test_dialog(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + let challenge = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx).placeholder("Challenge (hex)") + }); + let view = cx.entity().downgrade(); + let submit = { + let challenge = challenge.clone(); + let view = view.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let chal = match hex::decode(challenge.read(cx).text().to_string().trim()) { + Ok(b) if !b.is_empty() => b, + _ => { + let _ = view.update(cx, |_, cx| { + cx.emit(SlotsEvent::Notification("Enter a valid hex challenge".into())); + }); + return; + } + }; + window.close_dialog(cx); + let status = dialog::open_status_dialog("Challenge-Response", window, cx); + let _ = view.update(cx, |this, cx| this.execute_test(slot, chal, status, cx)); + }) + }; + window.open_dialog(cx, move |dialog, _window, _| { + let challenge = challenge.clone(); + let submit_ok = submit.clone(); + let submit_btn = submit.clone(); + dialog + .title(format!("Test Slot {slot}")) + .child("Send a challenge; the slot answers with its HMAC-SHA1 response.") + .child( + gpui_component::v_flex() + .gap_2() + .pb_2() + .child("Challenge (hex)") + .child(gpui_component::input::Input::new(&challenge)), + ) + .on_ok(move |_, window, cx| { + submit_ok(window, cx); + false + }) + .footer(move |_, _window, _cx, _| { + let submit = submit_btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("run") + .primary() + .label("Run") + .on_click(move |_, window, cx| submit(window, cx)), + ] + }) + }); + } + + fn execute_test( + &mut self, + slot: u8, + challenge: Vec, + status: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx + .background_executor() + .spawn(async move { DeviceRepo::otp_calculate_blocking(slot, challenge) }) + .await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(resp) => { + let _ = status.update(cx, |d, cx| { + d.set_success(format!("Response: {}", hex::encode(resp)), cx) + }); + } + Err(e) => { + let _ = status.update(cx, |d, cx| d.set_error(format!("{e}"), cx)); + } + } + cx.notify(); + }); + })); + } + + /// Shared plumbing: run a blocking OTP op off-thread, report via `finish`, + /// and reload slot status on success. + fn run_op( + &mut self, + cx: &mut Context, + op: impl FnOnce(()) -> Result<(), PFError> + Send + 'static, + ok_msg: &'static str, + finish: impl FnOnce(&mut Context, String, bool) + 'static, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + let weak = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let res = cx.background_executor().spawn(async move { op(()) }).await; + let _ = weak.update(cx, |this, cx| { + this.loading = false; + match res { + Ok(_) => { + finish(cx, ok_msg.to_string(), true); + this.load(cx); + } + Err(e) => finish(cx, format!("{e}"), false), + } + cx.notify(); + }); + })); + } +} From e54442803c5ea9fceeac04e5a5adda47247ece2f Mon Sep 17 00:00:00 2001 From: Maxim Muravev Date: Mon, 27 Jul 2026 20:41:50 +0300 Subject: [PATCH 4/7] fmt: Run cargo fmt --- src/hal/apdu/mod.rs | 23 +++- src/hal/applets/oath.rs | 53 +++++-- src/hal/applets/openpgp.rs | 124 +++++++++++++---- src/hal/applets/otp.rs | 39 ++++-- src/hal/applets/piv.rs | 144 +++++++++++++++---- src/hal/fido/audit.rs | 30 +++- src/hal/fido/mod.rs | 63 +++++---- src/hal/fido/ops.rs | 20 ++- src/hal/io.rs | 19 +-- src/hal/offboard.rs | 49 +++++-- src/hal/transport/ccid.rs | 12 +- src/ui/app.rs | 25 ++-- src/ui/components/sidebar.rs | 21 +-- src/ui/models/device.rs | 33 +++-- src/ui/screens/accounts/view.rs | 8 +- src/ui/screens/accounts/view_model.rs | 52 +++---- src/ui/screens/attestation/view.rs | 34 ++++- src/ui/screens/attestation/view_model.rs | 2 +- src/ui/screens/audit/view.rs | 48 +++++-- src/ui/screens/audit/view_model.rs | 17 ++- src/ui/screens/backup/view.rs | 21 ++- src/ui/screens/backup/view_model.rs | 9 +- src/ui/screens/config/view.rs | 5 +- src/ui/screens/config/view_model.rs | 5 +- src/ui/screens/home/view.rs | 78 +++++------ src/ui/screens/lock/view.rs | 93 +++++++------ src/ui/screens/lock/view_model.rs | 10 +- src/ui/screens/offboard/view.rs | 25 +++- src/ui/screens/offboard/view_model.rs | 23 ++-- src/ui/screens/openpgp/view.rs | 50 +++++-- src/ui/screens/openpgp/view_model.rs | 55 +++++--- src/ui/screens/piv/view.rs | 83 +++++++++-- src/ui/screens/piv/view_model.rs | 167 +++++++++++++++++------ src/ui/screens/slots/program_form.rs | 58 +++++--- src/ui/screens/slots/view.rs | 2 +- src/ui/screens/slots/view_model.rs | 30 +++- 36 files changed, 1087 insertions(+), 443 deletions(-) diff --git a/src/hal/apdu/mod.rs b/src/hal/apdu/mod.rs index fe5aa79..691fc00 100644 --- a/src/hal/apdu/mod.rs +++ b/src/hal/apdu/mod.rs @@ -40,12 +40,26 @@ pub struct Apdu { impl Apdu { /// A command that expects a response (`Le = 0` short form). pub fn read(cla: u8, ins: u8, p1: u8, p2: u8, data: &[u8]) -> Self { - Self { cla, ins, p1, p2, data: data.to_vec(), le: Some(0) } + Self { + cla, + ins, + p1, + p2, + data: data.to_vec(), + le: Some(0), + } } /// A pure write (no `Le`), for commands that answer with only a status word. pub fn write(cla: u8, ins: u8, p1: u8, p2: u8, data: &[u8]) -> Self { - Self { cla, ins, p1, p2, data: data.to_vec(), le: None } + Self { + cla, + ins, + p1, + p2, + data: data.to_vec(), + le: None, + } } /// Encode to the wire. Short-form Lc/Le when the body is ≤255 bytes, @@ -135,7 +149,10 @@ mod tests { #[test] fn encodes_case2_no_data_with_le() { // LIST: header + single Le byte (0 = 256). - assert_eq!(Apdu::read(0x00, 0xA1, 0, 0, &[]).encode(), vec![0x00, 0xA1, 0, 0, 0x00]); + assert_eq!( + Apdu::read(0x00, 0xA1, 0, 0, &[]).encode(), + vec![0x00, 0xA1, 0, 0, 0x00] + ); } #[test] diff --git a/src/hal/applets/oath.rs b/src/hal/applets/oath.rs index 46e0686..6508b48 100644 --- a/src/hal/applets/oath.rs +++ b/src/hal/applets/oath.rs @@ -12,7 +12,7 @@ #![allow(dead_code)] use crate::error::PFError; -use crate::hal::apdu::{tlv, Apdu, CLA_ISO}; +use crate::hal::apdu::{Apdu, CLA_ISO, tlv}; use crate::hal::transport::ccid::CcidSession; use ring::rand::{SecureRandom, SystemRandom}; use ring::{hmac, pbkdf2}; @@ -245,7 +245,12 @@ pub fn clear_code(session: &CcidSession) -> Result<(), PFError> { /// Add or overwrite a credential. pub fn put(session: &CcidSession, cred: &NewCredential) -> Result<(), PFError> { - let id = build_cred_id(cred.issuer.as_deref(), &cred.account, cred.oath_type, cred.period); + let id = build_cred_id( + cred.issuer.as_deref(), + &cred.account, + cred.oath_type, + cred.period, + ); let mut key_tlv = vec![cred.oath_type.wire() | cred.algorithm.wire(), cred.digits]; key_tlv.extend_from_slice(&cred.secret); @@ -318,7 +323,9 @@ pub fn calculate_all(session: &CcidSession) -> Result, PFError> { pending = Some((id, OathType::Totp)); continue; } - let Some((id, _)) = pending.take() else { continue }; + let Some((id, _)) = pending.take() else { + continue; + }; let (issuer, account, period) = parse_cred_id(&id); let (oath_type, state) = match tag { TAG_NO_RESPONSE => (OathType::Hotp, CodeState::Hotp), @@ -327,7 +334,10 @@ pub fn calculate_all(session: &CcidSession) -> Result, PFError> { let value_str = format_response(value)?; ( OathType::Totp, - CodeState::Code { value: value_str, period }, + CodeState::Code { + value: value_str, + period, + }, ) } _ => continue, @@ -380,7 +390,9 @@ fn time_counter(period: u32) -> u64 { /// Handles both truncated (`0x76`: `[digits, 4-byte int]`) and full /// (`0x75`: `[digits, full HMAC]`, truncated host-side) forms. fn format_response(value: &[u8]) -> Result { - let digits = *value.first().ok_or_else(|| PFError::Device("Short code response".into()))?; + let digits = *value + .first() + .ok_or_else(|| PFError::Device("Short code response".into()))?; let body = &value[1..]; let num = if body.len() == 4 { u32::from_be_bytes([body[0], body[1], body[2], body[3]]) & 0x7FFF_FFFF @@ -402,7 +414,12 @@ fn format_response(value: &[u8]) -> Result { /// Build the Yubico credential id: `[/]:`, with the /// period prefix only for non-30 s TOTP credentials. -pub fn build_cred_id(issuer: Option<&str>, account: &str, oath_type: OathType, period: u32) -> String { +pub fn build_cred_id( + issuer: Option<&str>, + account: &str, + oath_type: OathType, + period: u32, +) -> String { let base = match issuer { Some(i) if !i.is_empty() => format!("{i}:{account}"), _ => account.to_string(), @@ -476,8 +493,8 @@ pub fn parse_otpauth(uri: &str) -> Result { } } - let secret = base32_decode(&secret_b32.ok_or("Missing secret")?) - .ok_or("Invalid base32 secret")?; + let secret = + base32_decode(&secret_b32.ok_or("Missing secret")?).ok_or("Invalid base32 secret")?; if secret.is_empty() { return Err("Empty secret".into()); } @@ -576,7 +593,10 @@ mod tests { #[test] fn base32_decodes_known_vectors() { - assert_eq!(base32_decode("JBSWY3DPEHPK3PXP").unwrap(), b"Hello!\xde\xad\xbe\xef"); + assert_eq!( + base32_decode("JBSWY3DPEHPK3PXP").unwrap(), + b"Hello!\xde\xad\xbe\xef" + ); assert_eq!(base32_decode("").unwrap(), Vec::::new()); // Lowercase, spaces and padding tolerated. assert_eq!(base32_decode("nb sw y3=dp").unwrap(), b"hello"); @@ -585,15 +605,24 @@ mod tests { #[test] fn cred_id_roundtrips() { - assert_eq!(build_cred_id(Some("GitHub"), "alice", OathType::Totp, 30), "GitHub:alice"); - assert_eq!(build_cred_id(Some("AWS"), "bob", OathType::Totp, 60), "60/AWS:bob"); + assert_eq!( + build_cred_id(Some("GitHub"), "alice", OathType::Totp, 30), + "GitHub:alice" + ); + assert_eq!( + build_cred_id(Some("AWS"), "bob", OathType::Totp, 60), + "60/AWS:bob" + ); assert_eq!(build_cred_id(None, "solo", OathType::Hotp, 30), "solo"); assert_eq!( parse_cred_id("GitHub:alice"), (Some("GitHub".into()), "alice".into(), 30) ); - assert_eq!(parse_cred_id("60/AWS:bob"), (Some("AWS".into()), "bob".into(), 60)); + assert_eq!( + parse_cred_id("60/AWS:bob"), + (Some("AWS".into()), "bob".into(), 60) + ); assert_eq!(parse_cred_id("solo"), (None, "solo".into(), 30)); // A slash that isn't a period prefix stays part of the account. assert_eq!(parse_cred_id("a/b:c"), (Some("a/b".into()), "c".into(), 30)); diff --git a/src/hal/applets/openpgp.rs b/src/hal/applets/openpgp.rs index ff24f12..35b6c14 100644 --- a/src/hal/applets/openpgp.rs +++ b/src/hal/applets/openpgp.rs @@ -12,7 +12,7 @@ #![allow(dead_code)] use crate::error::PFError; -use crate::hal::apdu::{tlv, Apdu, CLA_ISO}; +use crate::hal::apdu::{Apdu, CLA_ISO, tlv}; use crate::hal::transport::ccid::CcidSession; pub const OPENPGP_AID: &[u8] = &[0xD2, 0x76, 0x00, 0x01, 0x24, 0x01]; @@ -142,7 +142,11 @@ pub const GENERATE_ALGOS: &[(&str, u8)] = &[ /// Build the algorithm-attribute bytes for a slot + choice, or `None` if the /// combination is unsupported (e.g. Ed25519 on the encryption slot). pub fn algo_attr(slot: PgpSlot, choice: u8) -> Option> { - let ec_id = if slot == PgpSlot::Dec { ALGO_ECDH } else { ALGO_ECDSA }; + let ec_id = if slot == PgpSlot::Dec { + ALGO_ECDH + } else { + ALGO_ECDSA + }; let ec = |oid: &[u8]| { let mut v = vec![ec_id]; v.extend_from_slice(oid); @@ -214,7 +218,13 @@ pub fn open() -> Result { } fn get_data(session: &CcidSession, tag: u16) -> Result, PFError> { - session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_DATA, (tag >> 8) as u8, tag as u8, &[])) + session.transceive_full(&Apdu::read( + CLA_ISO, + INS_GET_DATA, + (tag >> 8) as u8, + tag as u8, + &[], + )) } pub fn get_version(session: &CcidSession) -> Result<[u8; 3], PFError> { @@ -255,14 +265,14 @@ pub fn read_info(session: &CcidSession) -> Result { let key_info = tlv::find(disc, 0xDE).unwrap_or(&[]); let mut keys = Vec::new(); - for (i, slot) in [PgpSlot::Sig, PgpSlot::Dec, PgpSlot::Aut].into_iter().enumerate() { + for (i, slot) in [PgpSlot::Sig, PgpSlot::Dec, PgpSlot::Aut] + .into_iter() + .enumerate() + { let attr = tlv::find(disc, slot.attr_tag() as u32).unwrap_or(&[]); let fp = fps.get(i * 20..i * 20 + 20).unwrap_or(&[]); - let present = fp.iter().any(|&b| b != 0) - || key_info - .get(i * 2 + 1) - .map(|&b| b != 0) - .unwrap_or(false); + let present = + fp.iter().any(|&b| b != 0) || key_info.get(i * 2 + 1).map(|&b| b != 0).unwrap_or(false); let touch = tlv::find(disc, slot.uif_tag() as u32) .and_then(|u| u.first()) .map(|&b| b != 0) @@ -284,8 +294,12 @@ pub fn read_info(session: &CcidSession) -> Result { let sex = tlv::find(ch, 0x5F35) .and_then(|v| v.first().copied()) .unwrap_or(0x39); - let login = get_data(session, 0x5E).map(|v| str_of(&v)).unwrap_or_default(); - let url = get_data(session, 0x5F50).map(|v| str_of(&v)).unwrap_or_default(); + let login = get_data(session, 0x5E) + .map(|v| str_of(&v)) + .unwrap_or_default(); + let url = get_data(session, 0x5F50) + .map(|v| str_of(&v)) + .unwrap_or_default(); Ok(PgpInfo { version, @@ -303,22 +317,41 @@ pub fn read_info(session: &CcidSession) -> Result { } fn str_of(v: &[u8]) -> String { - String::from_utf8_lossy(v).trim_end_matches('\0').to_string() + String::from_utf8_lossy(v) + .trim_end_matches('\0') + .to_string() } // ── PIN management ────────────────────────────────────────────────────────── pub fn verify_pin(session: &CcidSession, reference: u8, pin: &str) -> Result<(), PFError> { - session.transceive_full(&Apdu::write(CLA_ISO, INS_VERIFY, 0x00, reference, pin.as_bytes()))?; + session.transceive_full(&Apdu::write( + CLA_ISO, + INS_VERIFY, + 0x00, + reference, + pin.as_bytes(), + ))?; Ok(()) } /// Change PW1 (`ref=PW1`) or PW3 (`ref=PW3`). The device splits old/new at the /// stored PIN length, so send `old ‖ new` concatenated. -pub fn change_pin(session: &CcidSession, reference: u8, old: &str, new: &str) -> Result<(), PFError> { +pub fn change_pin( + session: &CcidSession, + reference: u8, + old: &str, + new: &str, +) -> Result<(), PFError> { let mut body = old.as_bytes().to_vec(); body.extend_from_slice(new.as_bytes()); - session.transceive_full(&Apdu::write(CLA_ISO, INS_CHANGE_REF, 0x00, reference, &body))?; + session.transceive_full(&Apdu::write( + CLA_ISO, + INS_CHANGE_REF, + 0x00, + reference, + &body, + ))?; Ok(()) } @@ -332,7 +365,13 @@ pub fn unblock_with_rc(session: &CcidSession, rc: &str, new_pw1: &str) -> Result /// Unblock PW1 using a verified admin PIN (call `verify_pin(PW3)` first). pub fn unblock_with_admin(session: &CcidSession, new_pw1: &str) -> Result<(), PFError> { - session.transceive_full(&Apdu::write(CLA_ISO, INS_RESET_RETRY, 0x02, PW1, new_pw1.as_bytes()))?; + session.transceive_full(&Apdu::write( + CLA_ISO, + INS_RESET_RETRY, + 0x02, + PW1, + new_pw1.as_bytes(), + ))?; Ok(()) } @@ -371,7 +410,11 @@ pub fn set_cardholder( } pub fn set_touch(session: &CcidSession, slot: PgpSlot, on: bool) -> Result<(), PFError> { - put_data(session, slot.uif_tag(), &[if on { 0x01 } else { 0x00 }, 0x20]) + put_data( + session, + slot.uif_tag(), + &[if on { 0x01 } else { 0x00 }, 0x20], + ) } pub fn set_algo_attr(session: &CcidSession, slot: PgpSlot, attr: &[u8]) -> Result<(), PFError> { @@ -379,13 +422,15 @@ pub fn set_algo_attr(session: &CcidSession, slot: PgpSlot, attr: &[u8]) -> Resul } /// Set the slot's algorithm then GENERATE a key (returns the `7F49` public key). -pub fn generate( - session: &CcidSession, - slot: PgpSlot, - attr: &[u8], -) -> Result, PFError> { +pub fn generate(session: &CcidSession, slot: PgpSlot, attr: &[u8]) -> Result, PFError> { set_algo_attr(session, slot, attr)?; - session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERATE, 0x80, 0x00, &[slot.crt(), 0x00])) + session.transceive_full(&Apdu::read( + CLA_ISO, + INS_GENERATE, + 0x80, + 0x00, + &[slot.crt(), 0x00], + )) } // ── Factory reset (block both PINs → TERMINATE → ACTIVATE) ─────────────────── @@ -393,7 +438,13 @@ pub fn generate( pub fn reset(session: &CcidSession) -> Result<(), PFError> { for reference in [PW1, PW3] { for _ in 0..10 { - match session.transceive(&Apdu::write(CLA_ISO, INS_VERIFY, 0x00, reference, b"00000000")) { + match session.transceive(&Apdu::write( + CLA_ISO, + INS_VERIFY, + 0x00, + reference, + b"00000000", + )) { Ok((_, sw)) if sw.0 == 0x6983 => break, Ok(_) => continue, Err(e) => return Err(e), @@ -411,8 +462,14 @@ mod tests { #[test] fn algo_attr_bytes() { - assert_eq!(algo_attr(PgpSlot::Sig, 0).unwrap(), vec![0x01, 0x08, 0x00, 0x00, 0x20, 0x00]); - assert_eq!(algo_attr(PgpSlot::Sig, 2).unwrap(), vec![0x01, 0x10, 0x00, 0x00, 0x20, 0x00]); + assert_eq!( + algo_attr(PgpSlot::Sig, 0).unwrap(), + vec![0x01, 0x08, 0x00, 0x00, 0x20, 0x00] + ); + assert_eq!( + algo_attr(PgpSlot::Sig, 2).unwrap(), + vec![0x01, 0x10, 0x00, 0x00, 0x20, 0x00] + ); // P-256: ECDSA on SIG, ECDH on DEC, same OID. assert_eq!(algo_attr(PgpSlot::Sig, 3).unwrap()[0], ALGO_ECDSA); assert_eq!(algo_attr(PgpSlot::Dec, 3).unwrap()[0], ALGO_ECDH); @@ -424,9 +481,18 @@ mod tests { #[test] fn labels_from_attrs() { - assert_eq!(algo_label(&[0x01, 0x10, 0x00, 0x00, 0x20, 0x00]), "RSA-4096"); - assert_eq!(algo_label(&[0x13, 0x2B, 0x81, 0x04, 0x00, 0x22]), "ECC P-384"); - assert_eq!(algo_label(&[0x16, 0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01]), "Ed25519"); + assert_eq!( + algo_label(&[0x01, 0x10, 0x00, 0x00, 0x20, 0x00]), + "RSA-4096" + ); + assert_eq!( + algo_label(&[0x13, 0x2B, 0x81, 0x04, 0x00, 0x22]), + "ECC P-384" + ); + assert_eq!( + algo_label(&[0x16, 0x2B, 0x06, 0x01, 0x04, 0x01, 0xDA, 0x47, 0x0F, 0x01]), + "Ed25519" + ); } #[test] diff --git a/src/hal/applets/otp.rs b/src/hal/applets/otp.rs index 49e7329..8d0f15d 100644 --- a/src/hal/applets/otp.rs +++ b/src/hal/applets/otp.rs @@ -14,7 +14,7 @@ #![allow(dead_code)] use crate::error::PFError; -use crate::hal::apdu::{tlv, Apdu, CLA_ISO}; +use crate::hal::apdu::{Apdu, CLA_ISO, tlv}; use crate::hal::transport::ccid::CcidSession; use ring::rand::{SecureRandom, SystemRandom}; @@ -94,7 +94,11 @@ pub struct SlotInfo { impl SlotInfo { fn empty(slot: u8) -> Self { - Self { slot, kind: SlotType::Empty, touch: false } + Self { + slot, + kind: SlotType::Empty, + touch: false, + } } pub fn configured(&self) -> bool { self.kind != SlotType::Empty @@ -263,8 +267,12 @@ pub fn modhex_decode(s: &str) -> Option> { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(s.len() / 2); for pair in bytes.chunks(2) { - let hi = MODHEX.iter().position(|&c| c == pair[0].to_ascii_lowercase())? as u8; - let lo = MODHEX.iter().position(|&c| c == pair[1].to_ascii_lowercase())? as u8; + let hi = MODHEX + .iter() + .position(|&c| c == pair[0].to_ascii_lowercase())? as u8; + let lo = MODHEX + .iter() + .position(|&c| c == pair[1].to_ascii_lowercase())? as u8; out.push((hi << 4) | lo); } Some(out) @@ -447,7 +455,11 @@ fn pad_challenge(challenge: &[u8]) -> Result<[u8; CHALLENGE_FRAME], PFError> { let mut frame = [0u8; CHALLENGE_FRAME]; frame[..challenge.len()].copy_from_slice(challenge); if challenge.len() < CHALLENGE_FRAME { - let pad = if *challenge.last().unwrap() == 0x7F { 0x00 } else { 0x7F }; + let pad = if *challenge.last().unwrap() == 0x7F { + 0x00 + } else { + 0x7F + }; frame[challenge.len()..].fill(pad); } Ok(frame) @@ -475,8 +487,16 @@ mod tests { // Every builder must produce a frame that CRCs to the X.25 residual. assert_eq!(crc16(&build_chalresp(&[0x11; 20], false, &NO_ACC)), 0xF0B8); assert_eq!(crc16(&build_hotp(&[0xAB; 20], true, true, &NO_ACC)), 0xF0B8); - assert_eq!(crc16(&build_static(&[0x04, 0x05, 0x06], false, &NO_ACC)), 0xF0B8); - assert_eq!(crc16(&build_yubico_otp(&[1; 6], &[2; 6], &[3; 16], false, &NO_ACC)), 0xF0B8); + assert_eq!( + crc16(&build_static(&[0x04, 0x05, 0x06], false, &NO_ACC)), + 0xF0B8 + ); + assert_eq!( + crc16(&build_yubico_otp( + &[1; 6], &[2; 6], &[3; 16], false, &NO_ACC + )), + 0xF0B8 + ); } #[test] @@ -589,6 +609,9 @@ mod tests { assert!(pad_challenge(&[]).is_err()); assert!(pad_challenge(&[0u8; CHALLENGE_FRAME + 1]).is_err()); assert!(pad_challenge(&[0u8; CHALLENGE_FRAME]).is_ok()); - assert_eq!(pad_challenge(&[9u8; CHALLENGE_FRAME]).unwrap().len(), CHALLENGE_FRAME); + assert_eq!( + pad_challenge(&[9u8; CHALLENGE_FRAME]).unwrap().len(), + CHALLENGE_FRAME + ); } } diff --git a/src/hal/applets/piv.rs b/src/hal/applets/piv.rs index 1c6a560..beeb348 100644 --- a/src/hal/applets/piv.rs +++ b/src/hal/applets/piv.rs @@ -14,7 +14,7 @@ #![allow(dead_code)] use crate::error::PFError; -use crate::hal::apdu::{tlv, Apdu, CLA_CHAIN, CLA_ISO}; +use crate::hal::apdu::{Apdu, CLA_CHAIN, CLA_ISO, tlv}; use crate::hal::transport::ccid::CcidSession; use cbc::cipher::{Block, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit}; use ring::rand::{SecureRandom, SystemRandom}; @@ -223,18 +223,26 @@ fn get_metadata(session: &CcidSession, slot: u8) -> Result, PFError> { } pub fn parse_ref_status(resp: &[u8]) -> Option { - let is_default = tlv::find(resp, 0x05).map(|v| v.first() == Some(&1)).unwrap_or(false); + let is_default = tlv::find(resp, 0x05) + .map(|v| v.first() == Some(&1)) + .unwrap_or(false); let retry = tlv::find(resp, 0x06)?; if retry.len() < 2 { return None; } - Some(RefStatus { is_default, total: retry[0], left: retry[1] }) + Some(RefStatus { + is_default, + total: retry[0], + left: retry[1], + }) } pub fn parse_slot_meta(resp: &[u8]) -> Option { let algo = *tlv::find(resp, 0x01)?.first()?; let policy = tlv::find(resp, 0x02)?; - let origin = tlv::find(resp, 0x03).and_then(|v| v.first().copied()).unwrap_or(0); + let origin = tlv::find(resp, 0x03) + .and_then(|v| v.first().copied()) + .unwrap_or(0); Some(SlotMeta { algo, pin_policy: *policy.first().unwrap_or(&0), @@ -248,7 +256,9 @@ fn get_data(session: &CcidSession, object_id: &[u8]) -> Result, PFError> let mut body = Vec::new(); tlv::write(&mut body, TAG_DATA_PATH, object_id); let resp = session.transceive_full(&Apdu::read(CLA_ISO, INS_GET_DATA, 0x3F, 0xFF, &body))?; - Ok(tlv::find(&resp, TAG_DATA_OBJECT).map(|v| v.to_vec()).unwrap_or(resp)) + Ok(tlv::find(&resp, TAG_DATA_OBJECT) + .map(|v| v.to_vec()) + .unwrap_or(resp)) } /// Extract the bare DER certificate from a slot's data object (`53{70 …}`). @@ -260,31 +270,54 @@ pub fn cert_der(object: &[u8]) -> Option> { pub fn read_info(session: &CcidSession) -> Result { let version = get_version(session).unwrap_or([0; 3]); let serial = get_serial(session).unwrap_or(0); - let pin = get_metadata(session, REF_PIN).ok().and_then(|r| parse_ref_status(&r)); - let puk = get_metadata(session, REF_PUK).ok().and_then(|r| parse_ref_status(&r)); + let pin = get_metadata(session, REF_PIN) + .ok() + .and_then(|r| parse_ref_status(&r)); + let puk = get_metadata(session, REF_PUK) + .ok() + .and_then(|r| parse_ref_status(&r)); let (mgm_algo, mgm_default) = match get_metadata(session, SLOT_MGM) { Ok(r) => ( - tlv::find(&r, 0x01).and_then(|v| v.first().copied()).unwrap_or(ALGO_AES192), - tlv::find(&r, 0x05).map(|v| v.first() == Some(&1)).unwrap_or(false), + tlv::find(&r, 0x01) + .and_then(|v| v.first().copied()) + .unwrap_or(ALGO_AES192), + tlv::find(&r, 0x05) + .map(|v| v.first() == Some(&1)) + .unwrap_or(false), ), Err(_) => (ALGO_AES192, false), }; let mut slots = Vec::new(); for &slot in &PRIMARY_SLOTS { - let meta = get_metadata(session, slot).ok().and_then(|r| parse_slot_meta(&r)); + let meta = get_metadata(session, slot) + .ok() + .and_then(|r| parse_slot_meta(&r)); let has_cert = get_data(session, &cert_object_id(slot)) .ok() .and_then(|o| cert_der(&o)) .map(|d| !d.is_empty()) .unwrap_or(false); - slots.push(SlotStatus { slot, meta, has_cert }); + slots.push(SlotStatus { + slot, + meta, + has_cert, + }); } let mgm_protected = mgm_is_protected(session); - Ok(PivInfo { version, serial, pin, puk, mgm_algo, mgm_default, mgm_protected, slots }) + Ok(PivInfo { + version, + serial, + pin, + puk, + mgm_algo, + mgm_default, + mgm_protected, + slots, + }) } // ── PIN-protected management key (ykman --protect) ─────────────────────────── @@ -385,7 +418,13 @@ pub fn change_ref( new: &str, ) -> Result<(), PFError> { let body = change_ref_body(old, new); - session.transceive_full(&Apdu::write(CLA_ISO, INS_CHANGE_REF, 0x00, reference, &body))?; + session.transceive_full(&Apdu::write( + CLA_ISO, + INS_CHANGE_REF, + 0x00, + reference, + &body, + ))?; Ok(()) } @@ -422,7 +461,11 @@ fn aes_ecb(key: &[u8], block: &mut [u8; 16], encrypt: bool) -> Result<(), PFErro 16 => run!(aes::Aes128), 24 => run!(aes::Aes192), 32 => run!(aes::Aes256), - _ => return Err(PFError::Device("Management key must be AES (16/24/32 bytes)".into())), + _ => { + return Err(PFError::Device( + "Management key must be AES (16/24/32 bytes)".into(), + )); + } } Ok(()) } @@ -445,8 +488,15 @@ pub fn authenticate_mgm(session: &CcidSession, key: &[u8], algo: u8) -> Result<( tlv::write(&mut inner, TAG_WITNESS, &[]); inner }); - let r1 = session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERAL_AUTH, algo, SLOT_MGM, &req1))?; - let outer = tlv::find(&r1, TAG_DYN_AUTH).ok_or_else(|| PFError::Device("No 7C in auth".into()))?; + let r1 = session.transceive_full(&Apdu::read( + CLA_ISO, + INS_GENERAL_AUTH, + algo, + SLOT_MGM, + &req1, + ))?; + let outer = + tlv::find(&r1, TAG_DYN_AUTH).ok_or_else(|| PFError::Device("No 7C in auth".into()))?; let enc_witness = tlv::find(outer, TAG_WITNESS) .filter(|w| w.len() == 16) .ok_or_else(|| PFError::Device("Bad witness".into()))?; @@ -464,17 +514,26 @@ pub fn authenticate_mgm(session: &CcidSession, key: &[u8], algo: u8) -> Result<( tlv::write(&mut inner, TAG_CHALLENGE, &challenge); let mut req2 = Vec::new(); tlv::write(&mut req2, TAG_DYN_AUTH, &inner); - let r2 = session.transceive_full(&Apdu::read(CLA_ISO, INS_GENERAL_AUTH, algo, SLOT_MGM, &req2))?; + let r2 = session.transceive_full(&Apdu::read( + CLA_ISO, + INS_GENERAL_AUTH, + algo, + SLOT_MGM, + &req2, + ))?; // Verify the card's response encrypts our challenge (mutual auth). - let outer2 = tlv::find(&r2, TAG_DYN_AUTH).ok_or_else(|| PFError::Device("No 7C in auth-2".into()))?; + let outer2 = + tlv::find(&r2, TAG_DYN_AUTH).ok_or_else(|| PFError::Device("No 7C in auth-2".into()))?; let resp = tlv::find(outer2, TAG_RESPONSE) .filter(|r| r.len() == 16) .ok_or_else(|| PFError::Device("Bad auth response".into()))?; let mut expect = challenge; aes_ecb(key, &mut expect, true)?; if expect.as_slice() != resp { - return Err(PFError::Device("Management-key authentication failed".into())); + return Err(PFError::Device( + "Management-key authentication failed".into(), + )); } Ok(()) } @@ -569,7 +628,13 @@ pub fn set_mgm(session: &CcidSession, algo: u8, key: &[u8], touch: bool) -> Resu /// Set PIN/PUK retry counts (requires mgmt AND PIN; resets PIN/PUK to defaults). pub fn set_retries(session: &CcidSession, pin_tries: u8, puk_tries: u8) -> Result<(), PFError> { - session.transceive_full(&Apdu::read(CLA_ISO, INS_SET_RETRIES, pin_tries, puk_tries, &[]))?; + session.transceive_full(&Apdu::read( + CLA_ISO, + INS_SET_RETRIES, + pin_tries, + puk_tries, + &[], + ))?; Ok(()) } @@ -591,7 +656,12 @@ pub fn delete_key(session: &CcidSession, slot: u8) -> Result<(), PFError> { } /// Import pre-built key-material TLVs into a slot (mgmt-gated). `algo` is P1. -pub fn import_key(session: &CcidSession, slot: u8, algo: u8, material: &[u8]) -> Result<(), PFError> { +pub fn import_key( + session: &CcidSession, + slot: u8, + algo: u8, + material: &[u8], +) -> Result<(), PFError> { let apdu = Apdu { cla: CLA_ISO, ins: INS_IMPORT, @@ -860,7 +930,10 @@ mod tests { // Also accepts a bare (un-53-wrapped) body. assert_eq!(parse_protected_mgm(&protected), Some(key.to_vec())); // A 7-byte "key" is neither 16/24/32 → rejected. - assert_eq!(parse_protected_mgm(&[0x88, 0x09, 0x89, 0x07, 0, 0, 0, 0, 0, 0, 0]), None); + assert_eq!( + parse_protected_mgm(&[0x88, 0x09, 0x89, 0x07, 0, 0, 0, 0, 0, 0, 0]), + None + ); assert_eq!(mgm_algo_for_len(16), ALGO_AES128); assert_eq!(mgm_algo_for_len(24), ALGO_AES192); assert_eq!(mgm_algo_for_len(32), ALGO_AES256); @@ -874,15 +947,26 @@ mod tests { // mis-verify and burn a retry. let body = change_ref_body("123456", "87654321"); assert_eq!(body.len(), 16); - assert_eq!(&body[..8], &[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF]); - assert_eq!(&body[8..], &[0x38, 0x37, 0x36, 0x35, 0x34, 0x33, 0x32, 0x31]); + assert_eq!( + &body[..8], + &[0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF] + ); + assert_eq!( + &body[8..], + &[0x38, 0x37, 0x36, 0x35, 0x34, 0x33, 0x32, 0x31] + ); } #[test] fn generate_template_bytes() { // AC { 80 01 11 AA 01 03 AB 01 02 } for P-256, PIN-always, touch-always. let t = generate_template(ALGO_ECCP256, PIN_POLICY_ALWAYS, TOUCH_POLICY_ALWAYS); - assert_eq!(t, vec![0xAC, 0x09, 0x80, 0x01, 0x11, 0xAA, 0x01, 0x03, 0xAB, 0x01, 0x02]); + assert_eq!( + t, + vec![ + 0xAC, 0x09, 0x80, 0x01, 0x11, 0xAA, 0x01, 0x03, 0xAB, 0x01, 0x02 + ] + ); // Default policies are omitted. let t2 = generate_template(ALGO_RSA2048, PIN_POLICY_DEFAULT, TOUCH_POLICY_DEFAULT); assert_eq!(t2, vec![0xAC, 0x03, 0x80, 0x01, 0x07]); @@ -919,8 +1003,14 @@ mod tests { #[test] fn pin_padding() { - assert_eq!(pad8(b"123456"), [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF]); - assert_eq!(pad8(b"12345678"), [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38]); + assert_eq!( + pad8(b"123456"), + [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0xFF, 0xFF] + ); + assert_eq!( + pad8(b"12345678"), + [0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] + ); } #[test] diff --git a/src/hal/fido/audit.rs b/src/hal/fido/audit.rs index 4263d21..df19cc5 100644 --- a/src/hal/fido/audit.rs +++ b/src/hal/fido/audit.rs @@ -187,7 +187,7 @@ pub fn build_journal( mod tests { use super::*; use ring::rand::SystemRandom; - use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_ASN1_SIGNING}; + use ring::signature::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair, KeyPair}; fn entry(seq: u32, event: u8) -> Vec { let mut e = vec![0u8; ENTRY_LEN]; @@ -233,8 +233,8 @@ mod tests { fn checkpoint_roundtrip_verifies_and_rejects_tamper() { let rng = SystemRandom::new(); let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng).unwrap(); - let kp = - EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng).unwrap(); + let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng) + .unwrap(); let pubkey = kp.public_key().as_ref().to_vec(); let head = [0x11u8; 32]; @@ -247,10 +247,28 @@ mod tests { msg.extend_from_slice(&challenge); let sig = kp.sign(&rng, &msg).unwrap(); - assert!(verify_checkpoint(&head, seq, sig.as_ref(), &pubkey, &challenge)); + assert!(verify_checkpoint( + &head, + seq, + sig.as_ref(), + &pubkey, + &challenge + )); // A different challenge must fail (freshness) and a bad key too. - assert!(!verify_checkpoint(&head, seq, sig.as_ref(), &pubkey, &[0x23u8; 16])); - assert!(!verify_checkpoint(&head, seq + 1, sig.as_ref(), &pubkey, &challenge)); + assert!(!verify_checkpoint( + &head, + seq, + sig.as_ref(), + &pubkey, + &[0x23u8; 16] + )); + assert!(!verify_checkpoint( + &head, + seq + 1, + sig.as_ref(), + &pubkey, + &challenge + )); } #[test] diff --git a/src/hal/fido/mod.rs b/src/hal/fido/mod.rs index 70bc7ba..008747e 100644 --- a/src/hal/fido/mod.rs +++ b/src/hal/fido/mod.rs @@ -798,8 +798,12 @@ fn parse_management_info(raw: &[u8]) -> Result { 0x02 if field_data.len() == 4 => { // TAG_SERIAL is the 8-digit Yubico decimal (already serial4-masked // by the firmware), big-endian — render it as ykman does, not hex. - let n = - u32::from_be_bytes([field_data[0], field_data[1], field_data[2], field_data[3]]); + let n = u32::from_be_bytes([ + field_data[0], + field_data[1], + field_data[2], + field_data[3], + ]); info.serial = Some(n.to_string()); } 0x03 => info.usb_enabled = parse_management_u16(field_data), @@ -1076,7 +1080,11 @@ fn build_rskey_phy_tlv(config: &AppConfigInput) -> Result, PFError> { tlv.push(0x00); } - if let Some(mfr) = config.manufacturer_name.as_deref().filter(|n| !n.is_empty()) { + if let Some(mfr) = config + .manufacturer_name + .as_deref() + .filter(|n| !n.is_empty()) + { let bytes = mfr.as_bytes(); if bytes.len() + 1 > 33 { return Err(PFError::Device( @@ -1513,7 +1521,9 @@ pub(crate) fn get_enterprise_attestation_csr() -> Result { fn vendor_error(status: u8, op: &str) -> String { match status { 0x36 => "device requires a PIN — enter it".to_string(), - 0x27 => "denied — no touch within the window (press the button when the LED blinks)".to_string(), + 0x27 => { + "denied — no touch within the window (press the button when the LED blinks)".to_string() + } 0x30 => format!("{op}: operation not allowed (already sealed, or no OTP DEVK provisioned)"), 0x3D => "device is not locked".to_string(), other => format!("{op} failed: status 0x{other:02x}"), @@ -1521,11 +1531,7 @@ fn vendor_error(status: u8, op: &str) -> String { } /// Require a successful vendor response and unwrap its CBOR map. -fn vendor_map( - status: u8, - map: Option, - op: &str, -) -> Result, String> { +fn vendor_map(status: u8, map: Option, op: &str) -> Result, String> { if status != 0 { return Err(vendor_error(status, op)); } @@ -1559,7 +1565,10 @@ fn m_bool(m: &BTreeMap, k: i128) -> bool { } /// Read AUDIT_READ into a journal window (gated by PIN, or a touch if `pin` is None). -fn read_journal(transport: &HidTransport, pin: Option<&str>) -> Result { +fn read_journal( + transport: &HidTransport, + pin: Option<&str>, +) -> Result { let (status, map) = transport .rs_key_vendor(RSKEY_VENDOR_AUDIT_READ, None, pin) .map_err(|e| e.to_string())?; @@ -1598,7 +1607,11 @@ pub(crate) fn audit_verify( let mut params = BTreeMap::new(); params.insert(Value::Integer(1), Value::Bytes(challenge.to_vec())); let (status, map) = transport - .rs_key_vendor(RSKEY_VENDOR_AUDIT_CHECKPOINT, Some(Value::Map(params)), pin.as_deref()) + .rs_key_vendor( + RSKEY_VENDOR_AUDIT_CHECKPOINT, + Some(Value::Map(params)), + pin.as_deref(), + ) .map_err(|e| e.to_string())?; let m = vendor_map(status, map, "checkpoint")?; @@ -1607,7 +1620,8 @@ pub(crate) fn audit_verify( let sig = m_bytes(&m, 3).ok_or("checkpoint: missing signature")?; let pubkey = m_bytes(&m, 4).ok_or("checkpoint: missing public key")?; - let signature_ok = audit::verify_checkpoint(&head_signed, seq_signed, &sig, &pubkey, &challenge); + let signature_ok = + audit::verify_checkpoint(&head_signed, seq_signed, &sig, &pubkey, &challenge); let head_matches = head_signed == journal.head; let fingerprint = audit::fingerprint(&pubkey); let pubkey_hex = hex::encode(&pubkey); @@ -1830,11 +1844,7 @@ pub(crate) fn lock_enable(pin: String) -> Result { HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; let blob = wrap_secret(&transport, &key)?; let token = transport - .get_pin_token_with_permission( - &pin, - PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, - None, - ) + .get_pin_token_with_permission(&pin, PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, None) .map_err(|e| e.to_string())?; transport .authconfig_vendor(&token, RSKEY_AUT_ENABLE, Some(Value::Bytes(blob))) @@ -1861,11 +1871,7 @@ pub(crate) fn lock_disable(pin: String, mnemonic: String) -> Result<(), String> } let token = transport - .get_pin_token_with_permission( - &pin, - PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, - None, - ) + .get_pin_token_with_permission(&pin, PinUvAuthTokenPermissions::AUTHENTICATOR_CONFIG, None) .map_err(|e| e.to_string())?; transport .authconfig_vendor(&token, RSKEY_AUT_DISABLE, None) @@ -1925,7 +1931,7 @@ fn certs_pem_to_der(input: &[u8]) -> Result, String> { Err("chain is neither PEM nor DER".into()) }; } - use base64::{engine::general_purpose, Engine as _}; + use base64::{Engine as _, engine::general_purpose}; let mut out = Vec::new(); let mut rest = text; while let Some(b) = rest.find("-----BEGIN CERTIFICATE-----") { @@ -1963,7 +1969,10 @@ pub(crate) fn att_import( let chain = certs_pem_to_der(&chain_file)?; if chain.is_empty() || chain.len() > 2048 { - return Err(format!("cert chain must be 1..=2048 bytes (got {})", chain.len())); + return Err(format!( + "cert chain must be 1..=2048 bytes (got {})", + chain.len() + )); } let transport = @@ -1974,7 +1983,11 @@ pub(crate) fn att_import( params.insert(Value::Integer(1), Value::Bytes(blob)); params.insert(Value::Integer(2), Value::Bytes(chain)); let (status, _) = transport - .rs_key_vendor(RSKEY_VENDOR_ATT_IMPORT, Some(Value::Map(params)), pin.as_deref()) + .rs_key_vendor( + RSKEY_VENDOR_ATT_IMPORT, + Some(Value::Map(params)), + pin.as_deref(), + ) .map_err(|e| e.to_string())?; if status != 0 { return Err(vendor_error(status, "attestation import")); diff --git a/src/hal/fido/ops.rs b/src/hal/fido/ops.rs index d4d8236..5158a9b 100644 --- a/src/hal/fido/ops.rs +++ b/src/hal/fido/ops.rs @@ -1594,7 +1594,8 @@ impl FidoOperations for HidTransport { // Touch-gated variants block until the button is pressed — allow ~30 s. const VENDOR_TOUCH_TIMEOUT_MS: i32 = 32_000; - let resp = self.send_raw_with_timeout(CTAPHID_CBOR, &full_payload, VENDOR_TOUCH_TIMEOUT_MS)?; + let resp = + self.send_raw_with_timeout(CTAPHID_CBOR, &full_payload, VENDOR_TOUCH_TIMEOUT_MS)?; if resp.is_empty() { return Err(PFError::Device("empty vendor response".into())); } @@ -1627,16 +1628,25 @@ impl FidoOperations for HidTransport { let sub_val = Value::Map(sub); let sub_bytes = to_vec(&sub_val).map_err(|e| PFError::Io(e.to_string()))?; - let pin_auth = - self.sign_config_command(pin_token, ConfigSubCommand::VendorPrototype as u8, &sub_bytes); + let pin_auth = self.sign_config_command( + pin_token, + ConfigSubCommand::VendorPrototype as u8, + &sub_bytes, + ); let mut cfg = BTreeMap::new(); cfg.insert( Value::Integer(ConfigParam::SubCommand as i128), Value::Integer(ConfigSubCommand::VendorPrototype as i128), ); - cfg.insert(Value::Integer(ConfigParam::SubCommandParams as i128), sub_val); - cfg.insert(Value::Integer(ConfigParam::PinUvAuthProtocol as i128), Value::Integer(1)); + cfg.insert( + Value::Integer(ConfigParam::SubCommandParams as i128), + sub_val, + ); + cfg.insert( + Value::Integer(ConfigParam::PinUvAuthProtocol as i128), + Value::Integer(1), + ); cfg.insert( Value::Integer(ConfigParam::PinUvAuthParam as i128), Value::Bytes(pin_auth), diff --git a/src/hal/io.rs b/src/hal/io.rs index 96fa83a..4401127 100644 --- a/src/hal/io.rs +++ b/src/hal/io.rs @@ -9,7 +9,7 @@ use crate::{ error::PFError, hal::{ applets::oath, applets::openpgp, applets::otp, applets::piv, fido, rescue, - transport::ccid::CcidSession, transport::DeviceHandle, types::*, + transport::DeviceHandle, transport::ccid::CcidSession, types::*, }, }; @@ -438,7 +438,11 @@ pub fn offboard(serial: String) -> Result Res } /// Generate a key in a slot with the given algorithm choice (see `GENERATE_ALGOS`). -pub fn openpgp_generate( - admin: String, - slot: openpgp::PgpSlot, - choice: u8, -) -> Result<(), PFError> { +pub fn openpgp_generate(admin: String, slot: openpgp::PgpSlot, choice: u8) -> Result<(), PFError> { let attr = openpgp::algo_attr(slot, choice).ok_or_else(|| { - PFError::Device(format!("Algorithm not supported for the {} slot", slot.label())) + PFError::Device(format!( + "Algorithm not supported for the {} slot", + slot.label() + )) })?; let s = openpgp_admin(&admin)?; openpgp::generate(&s, slot, &attr).map(|_| ()) diff --git a/src/hal/offboard.rs b/src/hal/offboard.rs index 96bfe26..c343441 100644 --- a/src/hal/offboard.rs +++ b/src/hal/offboard.rs @@ -48,7 +48,11 @@ impl OffboardReport { /// The names of the failed steps. pub fn failures(&self) -> Vec<&str> { - self.steps.iter().filter(|s| !s.ok).map(|s| s.name.as_str()).collect() + self.steps + .iter() + .filter(|s| !s.ok) + .map(|s| s.name.as_str()) + .collect() } /// Render the receipt as pretty JSON. `timestamp` is supplied by the caller @@ -59,7 +63,11 @@ impl OffboardReport { if i > 0 { steps.push(','); } - steps.push_str(&format!("\n {}: {}", json_str(&s.name), json_str(&s.detail))); + steps.push_str(&format!( + "\n {}: {}", + json_str(&s.name), + json_str(&s.detail) + )); } steps.push_str("\n }"); @@ -70,10 +78,22 @@ impl OffboardReport { out.push_str(&format!(" \"signed\": {}", self.signed)); if self.signed { let f = |o: &Option| o.clone().unwrap_or_default(); - out.push_str(&format!(",\n \"fingerprint\": {}", json_str(&f(&self.fingerprint)))); - out.push_str(&format!(",\n \"signed_head\": {}", json_str(&f(&self.signed_head)))); - out.push_str(&format!(",\n \"signature\": {}", json_str(&f(&self.signature)))); - out.push_str(&format!(",\n \"attestation_pubkey\": {}", json_str(&f(&self.pubkey)))); + out.push_str(&format!( + ",\n \"fingerprint\": {}", + json_str(&f(&self.fingerprint)) + )); + out.push_str(&format!( + ",\n \"signed_head\": {}", + json_str(&f(&self.signed_head)) + )); + out.push_str(&format!( + ",\n \"signature\": {}", + json_str(&f(&self.signature)) + )); + out.push_str(&format!( + ",\n \"attestation_pubkey\": {}", + json_str(&f(&self.pubkey)) + )); } out.push_str("\n}\n"); out @@ -85,7 +105,11 @@ mod tests { use super::*; fn step(name: &str, ok: bool) -> OffboardStep { - OffboardStep { name: name.into(), ok, detail: if ok { "ok".into() } else { "boom".into() } } + OffboardStep { + name: name.into(), + ok, + detail: if ok { "ok".into() } else { "boom".into() }, + } } #[test] @@ -120,8 +144,15 @@ mod tests { assert!(j.contains("\"fingerprint\": \"abcd\"")); assert!(j.contains("\"attestation_pubkey\": \"04aa\"")); // A quote in a detail string must be escaped. - let bad = OffboardStep { name: "x".into(), ok: false, detail: "a\"b".into() }; - let r2 = OffboardReport { steps: vec![bad], ..r }; + let bad = OffboardStep { + name: "x".into(), + ok: false, + detail: "a\"b".into(), + }; + let r2 = OffboardReport { + steps: vec![bad], + ..r + }; assert!(r2.to_json("t").contains("a\\\"b")); } } diff --git a/src/hal/transport/ccid.rs b/src/hal/transport/ccid.rs index c4b7456..f584ac2 100644 --- a/src/hal/transport/ccid.rs +++ b/src/hal/transport/ccid.rs @@ -12,7 +12,7 @@ use crate::error::PFError; use crate::hal::apdu::{ - Apdu, StatusWord, CLA_CHAIN, CLA_ISO, INS_GET_RESPONSE, INS_SELECT, INS_SEND_REMAINING, + Apdu, CLA_CHAIN, CLA_ISO, INS_GET_RESPONSE, INS_SELECT, INS_SEND_REMAINING, StatusWord, }; use pcsc::{Context, Protocols, Scope, ShareMode}; @@ -42,7 +42,10 @@ impl CcidSession { .ok_or(PFError::NoDevice)?; let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?; - let mut session = Self { card, select_resp: Vec::new() }; + let mut session = Self { + card, + select_resp: Vec::new(), + }; let select = Apdu::read(CLA_ISO, INS_SELECT, 0x04, 0x00, aid); session.select_resp = session.transceive_full(&select).map_err(|e| { PFError::Device(format!( @@ -61,7 +64,10 @@ impl CcidSession { return Err(PFError::Device("Truncated APDU response".into())); } let (data, sw) = resp.split_at(resp.len() - 2); - Ok((data.to_vec(), StatusWord(u16::from_be_bytes([sw[0], sw[1]])))) + Ok(( + data.to_vec(), + StatusWord(u16::from_be_bytes([sw[0], sw[1]])), + )) } /// Send an APDU and assemble the full response across `61xx` continuations, diff --git a/src/ui/app.rs b/src/ui/app.rs index c78cd31..9e6d5b9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -13,9 +13,8 @@ use crate::ui::screens::{ attestation::AttestationEvent, attestation::AttestationViewModel, audit::AuditViewModel, backup::BackupViewModel, config::ConfigViewModel, home::HomeViewModel, lock::LockViewModel, offboard::OffboardEvent, offboard::OffboardViewModel, openpgp::OpenPgpEvent, - openpgp::OpenPgpViewModel, passkeys::PasskeysEvent, - passkeys::PasskeysViewModel, piv::PivEvent, piv::PivViewModel, security::SecurityViewModel, - slots::SlotsEvent, slots::SlotsViewModel, + openpgp::OpenPgpViewModel, passkeys::PasskeysEvent, passkeys::PasskeysViewModel, piv::PivEvent, + piv::PivViewModel, security::SecurityViewModel, slots::SlotsEvent, slots::SlotsViewModel, }; use gpui::prelude::*; use gpui::*; @@ -239,15 +238,13 @@ impl Render for ApplicationRoot { Destination::Slots => { let view = self.views_store.slots.get_or_insert_with(|| { let view = cx.new(|cx| SlotsViewModel::new(window, cx, &self.models)); - cx.subscribe_in( - &view, - window, - |_, _, event: &SlotsEvent, window, cx| match event { + cx.subscribe_in(&view, window, |_, _, event: &SlotsEvent, window, cx| { + match event { SlotsEvent::Notification(msg) => { window.push_notification(msg.to_string(), cx); } - }, - ) + } + }) .detach(); view }); @@ -271,15 +268,13 @@ impl Render for ApplicationRoot { Destination::OpenPgp => { let view = self.views_store.openpgp.get_or_insert_with(|| { let view = cx.new(|cx| OpenPgpViewModel::new(window, cx, &self.models)); - cx.subscribe_in( - &view, - window, - |_, _, event: &OpenPgpEvent, window, cx| match event { + cx.subscribe_in(&view, window, |_, _, event: &OpenPgpEvent, window, cx| { + match event { OpenPgpEvent::Notification(msg) => { window.push_notification(msg.to_string(), cx); } - }, - ) + } + }) .detach(); view }); diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index 3f41ba9..b047c8c 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -179,14 +179,12 @@ impl Render for AppSidebar { // features, then device-wide system actions (Offboard sits just // above About as a bottom-of-list decommission action). .child( - SidebarGroup::new("Device").child( - SidebarMenu::new().child(self.menu_item( - cx, - "Home", - "icons/house.svg", - Destination::Home, - )), - ), + SidebarGroup::new("Device").child(SidebarMenu::new().child(self.menu_item( + cx, + "Home", + "icons/house.svg", + Destination::Home, + ))), ) .child( SidebarGroup::new("Credentials").child( @@ -227,12 +225,7 @@ impl Render for AppSidebar { "icons/book-open.svg", Destination::Audit, )) - .child(self.menu_item( - cx, - "Backup", - "icons/save.svg", - Destination::Backup, - )) + .child(self.menu_item(cx, "Backup", "icons/save.svg", Destination::Backup)) .child(self.menu_item(cx, "Lock", "icons/lock.svg", Destination::Lock)) .child(self.menu_item( cx, diff --git a/src/ui/models/device.rs b/src/ui/models/device.rs index c9fecab..c1d3cad 100644 --- a/src/ui/models/device.rs +++ b/src/ui/models/device.rs @@ -26,14 +26,14 @@ const HOTPLUG_POLL_MS: u64 = 1000; pub use crate::hal::applets::oath; pub use crate::hal::applets::openpgp; -pub use crate::hal::fido::audit; -pub use crate::hal::fido::backup; -pub use crate::hal::fido::AttStatus; -pub use crate::hal::offboard::OffboardReport; pub use crate::hal::applets::otp; -pub use crate::hal::io::MgmAuth; pub use crate::hal::applets::piv; pub use crate::hal::applets::{OathFeatures, OpenPgpFeatures, OtpFeatures, PivFeatures}; +pub use crate::hal::fido::AttStatus; +pub use crate::hal::fido::audit; +pub use crate::hal::fido::backup; +pub use crate::hal::io::MgmAuth; +pub use crate::hal::offboard::OffboardReport; pub use crate::hal::rescue::constants::{ LedColor, LedStatus, USB_CAP_FIDO2, USB_CAP_OATH, USB_CAP_OPENPGP, USB_CAP_OTP, USB_CAP_PIV, USB_CAP_U2F, @@ -204,10 +204,21 @@ impl DeviceRepo { new_acc: [u8; 6], current_acc: [u8; 6], ) -> Result<(), crate::error::PFError> { - io::otp_program_yubico(slot, public_id, private_id, key, append_cr, new_acc, current_acc) + io::otp_program_yubico( + slot, + public_id, + private_id, + key, + append_cr, + new_acc, + current_acc, + ) } - pub fn otp_delete_blocking(slot: u8, current_acc: [u8; 6]) -> Result<(), crate::error::PFError> { + pub fn otp_delete_blocking( + slot: u8, + current_acc: [u8; 6], + ) -> Result<(), crate::error::PFError> { io::otp_delete(slot, current_acc) } @@ -290,10 +301,7 @@ impl DeviceRepo { io::piv_import_key(slot, key_file, auth) } - pub fn piv_delete_cert_blocking( - slot: u8, - auth: MgmAuth, - ) -> Result<(), crate::error::PFError> { + pub fn piv_delete_cert_blocking(slot: u8, auth: MgmAuth) -> Result<(), crate::error::PFError> { io::piv_delete_cert(slot, auth) } @@ -396,7 +404,8 @@ impl DeviceRepo { /// OATH feature profile of the connected firmware, if it exposes the applet. pub fn oath_features(&self) -> Option { let status = self.status.as_ref()?; - AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).oath_features() + AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version) + .oath_features() } /// OTP feature profile of the connected firmware, if it exposes the applet. diff --git a/src/ui/screens/accounts/view.rs b/src/ui/screens/accounts/view.rs index bb4824e..a8f620d 100644 --- a/src/ui/screens/accounts/view.rs +++ b/src/ui/screens/accounts/view.rs @@ -6,7 +6,7 @@ use crate::ui::models::device::oath; use crate::ui::screens::accounts::view_model::AccountsViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; /// Split a numeric code into two halves for readability ("123 456"). fn format_code(code: &str) -> String { @@ -192,7 +192,11 @@ impl Render for AccountsViewModel { .justify_center() .gap_3() .py_6() - .child(div().font_semibold().child("Accounts are password-protected")) + .child( + div() + .font_semibold() + .child("Accounts are password-protected"), + ) .child( div() .text_sm() diff --git a/src/ui/screens/accounts/view_model.rs b/src/ui/screens/accounts/view_model.rs index 997e827..573be0b 100644 --- a/src/ui/screens/accounts/view_model.rs +++ b/src/ui/screens/accounts/view_model.rs @@ -6,12 +6,12 @@ use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::{ConfirmContent, PinPromptContent}; -use crate::ui::models::device::{oath, DeviceEvent, DeviceRepo, USB_CAP_OATH}; +use crate::ui::components::form::{LabeledU8, select_state, selected_key}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, USB_CAP_OATH, oath}; use gpui::*; -use gpui_component::button::ButtonVariants; -use crate::ui::components::form::{select_state, selected_key, LabeledU8}; -use gpui_component::select::SelectState; use gpui_component::WindowExt; +use gpui_component::button::ButtonVariants; +use gpui_component::select::SelectState; use std::time::{Duration, SystemTime, UNIX_EPOCH}; // Add-form dropdown options (label, key). The key is the wire value where one @@ -94,17 +94,19 @@ impl AccountsViewModel { } fn start_ticker(&mut self, cx: &mut Context) { - self._ticker = Some(cx.spawn(async move |weak, cx| loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - let alive = weak.update(cx, |this, cx| { - this.now = now_unix(); - if this.loaded && !this.loading && this.now / 30 != this.last_window { - this.reload(cx); + self._ticker = Some(cx.spawn(async move |weak, cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let alive = weak.update(cx, |this, cx| { + this.now = now_unix(); + if this.loaded && !this.loading && this.now / 30 != this.last_window { + this.reload(cx); + } + cx.notify(); + }); + if alive.is_err() { + break; } - cx.notify(); - }); - if alive.is_err() { - break; } })); } @@ -346,7 +348,10 @@ impl AccountsViewModel { match res { Ok(code) => { if let Some(acc) = this.accounts.iter_mut().find(|a| a.id == id) { - acc.state = oath::CodeState::Code { value: code, period }; + acc.state = oath::CodeState::Code { + value: code, + period, + }; } } Err(e) => cx.emit(AccountsEvent::Notification(format!("Calculate: {e}"))), @@ -361,7 +366,8 @@ impl AccountsViewModel { gpui_component::input::InputState::new(window, cx).placeholder("Issuer (e.g. GitHub)") }); let account = cx.new(|cx| { - gpui_component::input::InputState::new(window, cx).placeholder("Account (e.g. you@example.com)") + gpui_component::input::InputState::new(window, cx) + .placeholder("Account (e.g. you@example.com)") }); let secret = cx.new(|cx| { gpui_component::input::InputState::new(window, cx) @@ -430,8 +436,7 @@ impl AccountsViewModel { match parsed { Ok(cred) => { window.close_dialog(cx); - let status = - dialog::open_status_dialog("Adding Account", window, cx); + let status = dialog::open_status_dialog("Adding Account", window, cx); let _ = view.update(cx, |this, cx| this.execute_add(cred, status, cx)); } Err(e) => { @@ -530,7 +535,8 @@ impl AccountsViewModel { this.loading = false; match res { Ok(_) => { - let _ = status.update(cx, |d, cx| d.set_success("Account added.".into(), cx)); + let _ = + status.update(cx, |d, cx| d.set_success("Account added.".into(), cx)); this.reload(cx); } Err(e) => { @@ -643,7 +649,8 @@ impl AccountsViewModel { this.loading = false; match res { Ok(_) => { - let _ = status.update(cx, |d, cx| d.set_success("Account renamed.".into(), cx)); + let _ = + status.update(cx, |d, cx| d.set_success("Account renamed.".into(), cx)); this.reload(cx); } Err(e) => { @@ -744,9 +751,8 @@ impl AccountsViewModel { this.loading = false; match res { Ok(_) => { - let _ = status.update(cx, |d, cx| { - d.set_success("OATH applet reset.".into(), cx) - }); + let _ = status + .update(cx, |d, cx| d.set_success("OATH applet reset.".into(), cx)); this.accounts.clear(); this.loaded = false; this.password = None; diff --git a/src/ui/screens/attestation/view.rs b/src/ui/screens/attestation/view.rs index c8bf9ea..9eb8093 100644 --- a/src/ui/screens/attestation/view.rs +++ b/src/ui/screens/attestation/view.rs @@ -5,7 +5,7 @@ use crate::ui::components::page_view::PageView; use crate::ui::screens::attestation::view_model::AttestationViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -17,7 +17,13 @@ fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { .border_color(theme.border) .rounded_xl() .child(div().font_semibold().child(heading.to_string())) - .child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body)) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) .into_any_element() } @@ -40,7 +46,12 @@ impl AttestationViewModel { v_flex() .gap_0p5() .child(div().font_medium().child(title)) - .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(subtitle), + ), ) .child(btn) } @@ -85,7 +96,15 @@ impl Render for AttestationViewModel { h_flex() .gap_2() .items_center() - .child(div().text_color(if s.installed { theme.green } else { theme.muted_foreground }).child("●")) + .child( + div() + .text_color(if s.installed { + theme.green + } else { + theme.muted_foreground + }) + .child("●"), + ) .child(div().text_sm().child(if s.installed { "Org attestation installed" } else { @@ -96,7 +115,12 @@ impl Render for AttestationViewModel { col = col.child( v_flex() .gap_0p5() - .child(div().text_xs().text_color(theme.muted_foreground).child("Chain hash")) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("Chain hash"), + ) .child(div().font_family("monospace").text_xs().child(h.clone())), ); } diff --git a/src/ui/screens/attestation/view_model.rs b/src/ui/screens/attestation/view_model.rs index cb465c4..18e0c4f 100644 --- a/src/ui/screens/attestation/view_model.rs +++ b/src/ui/screens/attestation/view_model.rs @@ -7,9 +7,9 @@ use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; use crate::ui::models::device::{AttStatus, DeviceEvent, DeviceRepo, FirmwareType}; use gpui::*; +use gpui_component::WindowExt; use gpui_component::button::ButtonVariants; use gpui_component::input::InputState; -use gpui_component::WindowExt; pub struct AttestationViewModel { pub(super) device: Entity, diff --git a/src/ui/screens/audit/view.rs b/src/ui/screens/audit/view.rs index 4fee01d..08404ea 100644 --- a/src/ui/screens/audit/view.rs +++ b/src/ui/screens/audit/view.rs @@ -6,7 +6,7 @@ use crate::ui::models::device::audit; use crate::ui::screens::audit::view_model::AuditViewModel; use gpui::*; use gpui_component::button::Button; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -48,10 +48,25 @@ impl AuditViewModel { .gap_3() .py_1() .text_sm() - .child(div().w(px(56.)).text_color(theme.muted_foreground).child(entry.seq.to_string())) - .child(div().w(px(72.)).text_color(theme.muted_foreground).child(format!("{:.1}s", entry.uptime_s()))) + .child( + div() + .w(px(56.)) + .text_color(theme.muted_foreground) + .child(entry.seq.to_string()), + ) + .child( + div() + .w(px(72.)) + .text_color(theme.muted_foreground) + .child(format!("{:.1}s", entry.uptime_s())), + ) .child(div().w(px(160.)).font_medium().child(entry.event_label())) - .child(div().w(px(40.)).text_color(theme.muted_foreground).child(entry.aux.to_string())) + .child( + div() + .w(px(40.)) + .text_color(theme.muted_foreground) + .child(entry.aux.to_string()), + ) .child( div() .flex_1() @@ -111,7 +126,10 @@ impl AuditViewModel { j.start, ))) .child(mono(theme, format!("epoch {}", short_hex(&j.epoch)))) - .child(mono(theme, format!("head {} (chain OK)", short_hex(&j.head)))), + .child(mono( + theme, + format!("head {} (chain OK)", short_hex(&j.head)), + )), ) .child(div().h(px(1.)).bg(theme.border)) .child(v_flex().gap_0p5().children(rows)) @@ -136,7 +154,12 @@ impl AuditViewModel { let kv = |k: &str, val: String| { v_flex() .gap_0p5() - .child(div().text_xs().text_color(theme.muted_foreground).child(k.to_string())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(k.to_string()), + ) .child(div().font_family("monospace").text_xs().child(val)) }; @@ -201,14 +224,18 @@ impl Render for AuditViewModel { .label("Disable") .outline() .disabled(self.loading) - .on_click(cx.listener(|this, _, window, cx| this.open_toggle(false, window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.open_toggle(false, window, cx)), + ), ), Some(false) => Some( Button::new("audit-enable") .label("Enable") .outline() .disabled(self.loading) - .on_click(cx.listener(|this, _, window, cx| this.open_toggle(true, window, cx))), + .on_click( + cx.listener(|this, _, window, cx| this.open_toggle(true, window, cx)), + ), ), None => None, }; @@ -218,7 +245,10 @@ impl Render for AuditViewModel { let verify_body = self.verify_body(theme); let (dot, status_text) = match self.enabled { - Some(true) => (theme.green, "On — recording security events to the key's flash."), + Some(true) => ( + theme.green, + "On — recording security events to the key's flash.", + ), Some(false) => ( theme.muted_foreground, "Off — journalling is opt-in; nothing is being recorded.", diff --git a/src/ui/screens/audit/view_model.rs b/src/ui/screens/audit/view_model.rs index c4cfa60..142a1e6 100644 --- a/src/ui/screens/audit/view_model.rs +++ b/src/ui/screens/audit/view_model.rs @@ -5,11 +5,11 @@ use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; -use crate::ui::models::device::{audit, DeviceEvent, DeviceRepo, FirmwareType}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, FirmwareType, audit}; use gpui::*; +use gpui_component::WindowExt; use gpui_component::button::ButtonVariants; use gpui_component::input::InputState; -use gpui_component::WindowExt; pub struct AuditViewModel { pub(super) device: Entity, @@ -67,7 +67,12 @@ impl AuditViewModel { // ── Enable / disable journalling (PIN + touch) ────────────────────────── - pub(super) fn open_toggle(&mut self, enable: bool, window: &mut Window, cx: &mut Context) { + pub(super) fn open_toggle( + &mut self, + enable: bool, + window: &mut Window, + cx: &mut Context, + ) { let pin = Self::pin_input(window, cx); let view = cx.entity().downgrade(); let submit = { @@ -77,7 +82,11 @@ impl AuditViewModel { let p = (!p.is_empty()).then_some(p); window.close_dialog(cx); let status = dialog::open_status_dialog( - if enable { "Enabling Journalling" } else { "Disabling Journalling" }, + if enable { + "Enabling Journalling" + } else { + "Disabling Journalling" + }, window, cx, ); diff --git a/src/ui/screens/backup/view.rs b/src/ui/screens/backup/view.rs index 8ae3e6b..929c9e1 100644 --- a/src/ui/screens/backup/view.rs +++ b/src/ui/screens/backup/view.rs @@ -5,7 +5,7 @@ use crate::ui::components::page_view::PageView; use crate::ui::screens::backup::view_model::BackupViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -46,7 +46,12 @@ impl BackupViewModel { v_flex() .gap_0p5() .child(div().font_medium().child(title)) - .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(subtitle), + ), ) .child(btn) } @@ -151,8 +156,16 @@ impl Render for BackupViewModel { }; v_flex() .gap_2() - .child(div().text_sm().child(format!("Seed present: {}", yn(s.has_seed)))) - .child(div().text_sm().child(format!("Export window: {export_state}"))) + .child( + div() + .text_sm() + .child(format!("Seed present: {}", yn(s.has_seed))), + ) + .child( + div() + .text_sm() + .child(format!("Export window: {export_state}")), + ) .into_any_element() } None => div() diff --git a/src/ui/screens/backup/view_model.rs b/src/ui/screens/backup/view_model.rs index 2550465..fef039b 100644 --- a/src/ui/screens/backup/view_model.rs +++ b/src/ui/screens/backup/view_model.rs @@ -4,11 +4,11 @@ use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; -use crate::ui::models::device::{backup, DeviceEvent, DeviceRepo, FirmwareType}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, FirmwareType, backup}; use gpui::*; +use gpui_component::WindowExt; use gpui_component::button::{ButtonVariant, ButtonVariants}; use gpui_component::input::InputState; -use gpui_component::WindowExt; pub struct BackupViewModel { pub(super) device: Entity, @@ -184,9 +184,8 @@ impl BackupViewModel { pub(super) fn open_restore(&mut self, window: &mut Window, cx: &mut Context) { let pin = Self::pin_input(window, cx); - let phrase = cx.new(|cx| { - InputState::new(window, cx).placeholder("24 words separated by spaces") - }); + let phrase = + cx.new(|cx| InputState::new(window, cx).placeholder("24 words separated by spaces")); let view = cx.entity().downgrade(); let submit = { let pin = pin.clone(); diff --git a/src/ui/screens/config/view.rs b/src/ui/screens/config/view.rs index ec9c7a3..86a4fca 100644 --- a/src/ui/screens/config/view.rs +++ b/src/ui/screens/config/view.rs @@ -310,8 +310,9 @@ impl ConfigViewModel { let inc_bright_listener = cx.listener(move |this, _, _, cx| { let b = this.led_status_brightness[i]; - this.led_status_brightness[i] = - b.saturating_add(LED_BRIGHTNESS_STEP).min(LED_BRIGHTNESS_MAX); + this.led_status_brightness[i] = b + .saturating_add(LED_BRIGHTNESS_STEP) + .min(LED_BRIGHTNESS_MAX); cx.notify(); }); diff --git a/src/ui/screens/config/view_model.rs b/src/ui/screens/config/view_model.rs index f81249a..13b06b5 100644 --- a/src/ui/screens/config/view_model.rs +++ b/src/ui/screens/config/view_model.rs @@ -866,7 +866,9 @@ impl ConfigViewModel { let final_led_driver = if sel_driver_idx == 0 { None } else { - LedDriverType::all().get(sel_driver_idx - 1).map(|d| d.value()) + LedDriverType::all() + .get(sel_driver_idx - 1) + .map(|d| d.value()) }; if final_led_driver != current_led_driver { has_changes = true; @@ -1188,7 +1190,6 @@ impl ConfigViewModel { cx.notify(); } - } #[cfg(test)] diff --git a/src/ui/screens/home/view.rs b/src/ui/screens/home/view.rs index 928c2e4..d08140f 100644 --- a/src/ui/screens/home/view.rs +++ b/src/ui/screens/home/view.rs @@ -130,9 +130,7 @@ impl HomeViewModel { .justify_between() .text_sm() .child( - div() - .text_color(theme.muted_foreground) - .child(flash_label), + div().text_color(theme.muted_foreground).child(flash_label), ) .child(div().text_color(theme.foreground).child( if let (Some(used), Some(total)) = @@ -153,46 +151,40 @@ impl HomeViewModel { this.child(Progress::new().value(flash_percent)) }, ) - .when_some( - info.flash_files.filter(|_| is_rskey), - |this, nfiles| { - this.child( - h_flex() - .justify_between() - .text_sm() - .child( - div() - .text_color(theme.muted_foreground) - .child("Stored objects"), - ) - .child( - div() - .text_color(theme.foreground) - .child(nfiles.to_string()), - ), - ) - }, - ) - .when_some( - info.flash_chip_size.filter(|_| is_rskey), - |this, chip| { - this.child( - h_flex() - .justify_between() - .text_sm() - .child( - div() - .text_color(theme.muted_foreground) - .child("Flash chip"), - ) - .child( - div() - .text_color(theme.foreground) - .child(Self::format_flash_size(chip)), - ), - ) - }, - ), + .when_some(info.flash_files.filter(|_| is_rskey), |this, nfiles| { + this.child( + h_flex() + .justify_between() + .text_sm() + .child( + div() + .text_color(theme.muted_foreground) + .child("Stored objects"), + ) + .child( + div() + .text_color(theme.foreground) + .child(nfiles.to_string()), + ), + ) + }) + .when_some(info.flash_chip_size.filter(|_| is_rskey), |this, chip| { + this.child( + h_flex() + .justify_between() + .text_sm() + .child( + div() + .text_color(theme.muted_foreground) + .child("Flash chip"), + ) + .child( + div() + .text_color(theme.foreground) + .child(Self::format_flash_size(chip)), + ), + ) + }), ), ) } diff --git a/src/ui/screens/lock/view.rs b/src/ui/screens/lock/view.rs index b27a32c..c76c7d3 100644 --- a/src/ui/screens/lock/view.rs +++ b/src/ui/screens/lock/view.rs @@ -5,7 +5,7 @@ use crate::ui::components::page_view::PageView; use crate::ui::screens::lock::view_model::LockViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -17,7 +17,13 @@ fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { .border_color(theme.border) .rounded_xl() .child(div().font_semibold().child(heading.to_string())) - .child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body)) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) .into_any_element() } @@ -40,7 +46,12 @@ impl LockViewModel { v_flex() .gap_0p5() .child(div().font_medium().child(title)) - .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(subtitle), + ), ) .child(btn) } @@ -133,45 +144,45 @@ impl Render for LockViewModel { let theme = cx.theme(); let status_card = { - let body = match status { - Some(s) => { - let state = if s.locked { - if s.unlocked { - "locked, unlocked for this power-cycle" + let body = + match status { + Some(s) => { + let state = if s.locked { + if s.unlocked { + "locked, unlocked for this power-cycle" + } else { + "locked — unlock before any FIDO login" + } } else { - "locked — unlock before any FIDO login" - } - } else { - "not locked (plaintext seed)" - }; - let (dot, color) = if s.locked && !s.unlocked { - ("●", theme.danger) - } else if s.locked { - ("●", theme.green) - } else { - ("●", theme.muted_foreground) - }; - v_flex() - .gap_2() - .child( - h_flex() - .gap_2() - .items_center() - .child(div().text_color(color).child(dot)) - .child(div().text_sm().child(format!("State: {state}"))), - ) - .child(div().text_sm().text_color(theme.muted_foreground).child(format!( - "Seed present: {}", - if s.has_seed { "yes" } else { "no" } - ))) - .into_any_element() - } - None => div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Reading lock state…") - .into_any_element(), - }; + "not locked (plaintext seed)" + }; + let (dot, color) = if s.locked && !s.unlocked { + ("●", theme.danger) + } else if s.locked { + ("●", theme.green) + } else { + ("●", theme.muted_foreground) + }; + v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .items_center() + .child(div().text_color(color).child(dot)) + .child(div().text_sm().child(format!("State: {state}"))), + ) + .child(div().text_sm().text_color(theme.muted_foreground).child( + format!("Seed present: {}", if s.has_seed { "yes" } else { "no" }), + )) + .into_any_element() + } + None => div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Reading lock state…") + .into_any_element(), + }; Card::new() .title("Lock status") .description("Whether the seed is wrapped at rest") diff --git a/src/ui/screens/lock/view_model.rs b/src/ui/screens/lock/view_model.rs index d631287..f0d0a35 100644 --- a/src/ui/screens/lock/view_model.rs +++ b/src/ui/screens/lock/view_model.rs @@ -4,11 +4,11 @@ use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; -use crate::ui::models::device::{backup, DeviceEvent, DeviceRepo, FirmwareType}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, FirmwareType, backup}; use gpui::*; +use gpui_component::WindowExt; use gpui_component::button::{ButtonVariant, ButtonVariants}; use gpui_component::input::InputState; -use gpui_component::WindowExt; pub struct LockViewModel { pub(super) device: Entity, @@ -83,7 +83,11 @@ impl LockViewModel { } fn pin_input(window: &mut Window, cx: &mut Context) -> Entity { - cx.new(|cx| InputState::new(window, cx).masked(true).placeholder("FIDO PIN (required)")) + cx.new(|cx| { + InputState::new(window, cx) + .masked(true) + .placeholder("FIDO PIN (required)") + }) } fn phrase_input(window: &mut Window, cx: &mut Context) -> Entity { diff --git a/src/ui/screens/offboard/view.rs b/src/ui/screens/offboard/view.rs index 02e8cbf..d3d8a19 100644 --- a/src/ui/screens/offboard/view.rs +++ b/src/ui/screens/offboard/view.rs @@ -5,7 +5,7 @@ use crate::ui::components::page_view::PageView; use crate::ui::screens::offboard::view_model::OffboardViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -17,7 +17,13 @@ fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { .border_color(theme.border) .rounded_xl() .child(div().font_semibold().child(heading.to_string())) - .child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body)) + .child( + div() + .text_sm() + .max_w(px(380.)) + .text_color(theme.muted_foreground) + .child(body), + ) .into_any_element() } @@ -42,7 +48,13 @@ impl OffboardViewModel { .items_center() .child(div().w(px(16.)).text_color(color).child(mark)) .child(div().w(px(120.)).font_medium().child(s.name.clone())) - .child(div().flex_1().text_sm().text_color(theme.muted_foreground).child(s.detail.clone())) + .child( + div() + .flex_1() + .text_sm() + .text_color(theme.muted_foreground) + .child(s.detail.clone()), + ) .into_any_element(), ); } @@ -74,7 +86,12 @@ impl OffboardViewModel { col = col.child( v_flex() .gap_0p5() - .child(div().text_xs().text_color(theme.muted_foreground).child("Attestation fingerprint (match against inventory)")) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("Attestation fingerprint (match against inventory)"), + ) .child(div().font_family("monospace").text_xs().child(fp.clone())), ); } diff --git a/src/ui/screens/offboard/view_model.rs b/src/ui/screens/offboard/view_model.rs index 2cb5218..948a9b6 100644 --- a/src/ui/screens/offboard/view_model.rs +++ b/src/ui/screens/offboard/view_model.rs @@ -6,9 +6,9 @@ use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; use crate::ui::models::device::{DeviceEvent, DeviceRepo, FirmwareType, OffboardReport}; use gpui::*; +use gpui_component::WindowExt; use gpui_component::button::ButtonVariants; use gpui_component::input::InputState; -use gpui_component::WindowExt; pub struct OffboardViewModel { pub(super) device: Entity, @@ -63,9 +63,8 @@ impl OffboardViewModel { pub(super) fn open_confirm(&mut self, window: &mut Window, cx: &mut Context) { let serial = self.serial(cx); - let confirm = cx.new(|cx| { - InputState::new(window, cx).placeholder("Type OFFBOARD to confirm") - }); + let confirm = + cx.new(|cx| InputState::new(window, cx).placeholder("Type OFFBOARD to confirm")); let view = cx.entity().downgrade(); let submit = { let confirm = confirm.clone(); @@ -73,7 +72,9 @@ impl OffboardViewModel { std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { if confirm.read(cx).text().to_string().trim() != "OFFBOARD" { let _ = view.update(cx, |_, cx| { - cx.emit(OffboardEvent::Notification("Type OFFBOARD exactly to confirm".into())) + cx.emit(OffboardEvent::Notification( + "Type OFFBOARD exactly to confirm".into(), + )) }); return; } @@ -130,7 +131,10 @@ impl OffboardViewModel { } self.loading = true; let _ = status.update(cx, |d, cx| { - d.set_loading("Wiping… touch the device (BOOTSEL) when it blinks (several times).", cx) + d.set_loading( + "Wiping… touch the device (BOOTSEL) when it blinks (several times).", + cx, + ) }); cx.notify(); let weak = cx.entity().downgrade(); @@ -147,7 +151,8 @@ impl OffboardViewModel { if report.signed { "Offboarded — all applets wiped, receipt signed.".to_string() } else { - "Offboarded — all applets wiped (receipt UNSIGNED: no OTP DEVK).".to_string() + "Offboarded — all applets wiped (receipt UNSIGNED: no OTP DEVK)." + .to_string() } } else { format!("Offboard finished WITH FAILURES: {:?}", report.failures()) @@ -170,7 +175,9 @@ impl OffboardViewModel { let Some(report) = self.report.clone() else { return; }; - let default_dir = std::env::var("HOME").map(std::path::PathBuf::from).unwrap_or_default(); + let default_dir = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) diff --git a/src/ui/screens/openpgp/view.rs b/src/ui/screens/openpgp/view.rs index 5885765..ce62806 100644 --- a/src/ui/screens/openpgp/view.rs +++ b/src/ui/screens/openpgp/view.rs @@ -6,7 +6,7 @@ use crate::ui::models::device::openpgp; use crate::ui::screens::openpgp::view_model::OpenPgpViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -31,7 +31,12 @@ fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { fn kv(label: &str, value: String, theme: &Theme) -> impl IntoElement { v_flex() .gap_1() - .child(div().text_sm().text_color(theme.muted_foreground).child(label.to_string())) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(label.to_string()), + ) .child(div().text_sm().font_medium().child(value)) } @@ -63,7 +68,11 @@ impl OpenPgpViewModel { .child( div() .text_sm() - .text_color(if k.present { theme.foreground } else { theme.muted_foreground }) + .text_color(if k.present { + theme.foreground + } else { + theme.muted_foreground + }) .child(status), ); if has_fp { @@ -127,7 +136,12 @@ impl OpenPgpViewModel { v_flex() .gap_0p5() .child(div().font_medium().child(title)) - .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(subtitle), + ), ) .child(btn) } @@ -192,8 +206,18 @@ impl Render for OpenPgpViewModel { let info_card = { let body = match &info { Some(i) => { - let serial = if i.serial != 0 { i.serial.to_string() } else { "—".into() }; - let field = |s: &str| if s.is_empty() { "—".to_string() } else { s.to_string() }; + let serial = if i.serial != 0 { + i.serial.to_string() + } else { + "—".into() + }; + let field = |s: &str| { + if s.is_empty() { + "—".to_string() + } else { + s.to_string() + } + }; div() .grid() .grid_cols(2) @@ -241,8 +265,18 @@ impl Render for OpenPgpViewModel { .child( v_flex() .gap_2() - .child(self.action_row("User PIN", "Change the user PIN (PW1)", change_user_btn, theme)) - .child(self.action_row("Admin PIN", "Change the admin PIN (PW3)", change_admin_btn, theme)) + .child(self.action_row( + "User PIN", + "Change the user PIN (PW1)", + change_user_btn, + theme, + )) + .child(self.action_row( + "Admin PIN", + "Change the admin PIN (PW3)", + change_admin_btn, + theme, + )) .child(self.action_row( "Unblock user PIN", "Reset a blocked user PIN with the reset code", diff --git a/src/ui/screens/openpgp/view_model.rs b/src/ui/screens/openpgp/view_model.rs index 14d77bb..1876337 100644 --- a/src/ui/screens/openpgp/view_model.rs +++ b/src/ui/screens/openpgp/view_model.rs @@ -7,10 +7,10 @@ use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; use crate::ui::components::form::{select_state, selected_key}; -use crate::ui::models::device::{openpgp, DeviceEvent, DeviceRepo, USB_CAP_OPENPGP}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, USB_CAP_OPENPGP, openpgp}; use gpui::*; -use gpui_component::button::ButtonVariants; use gpui_component::WindowExt; +use gpui_component::button::ButtonVariants; use openpgp::PgpSlot; const OPT_TOUCH: &[(&str, u8)] = &[("Off", 0), ("On", 1)]; @@ -73,7 +73,11 @@ impl OpenPgpViewModel { /// Whether the firmware advertises elliptic-curve keys (else RSA-only). fn ecc(&self, cx: &App) -> bool { - self.device.read(cx).openpgp_features().map(|f| f.ecc).unwrap_or(false) + self.device + .read(cx) + .openpgp_features() + .map(|f| f.ecc) + .unwrap_or(false) } fn load(&mut self, cx: &mut Context) { @@ -286,7 +290,12 @@ impl OpenPgpViewModel { // ── Generate ──────────────────────────────────────────────────────────── - pub(super) fn open_generate(&mut self, slot: PgpSlot, window: &mut Window, cx: &mut Context) { + pub(super) fn open_generate( + &mut self, + slot: PgpSlot, + window: &mut Window, + cx: &mut Context, + ) { let algos: &'static [(&str, u8)] = if self.ecc(cx) { openpgp::GENERATE_ALGOS } else { @@ -359,7 +368,12 @@ impl OpenPgpViewModel { // ── Touch policy ────────────────────────────────────────────────────────── - pub(super) fn open_touch(&mut self, slot: PgpSlot, window: &mut Window, cx: &mut Context) { + pub(super) fn open_touch( + &mut self, + slot: PgpSlot, + window: &mut Window, + cx: &mut Context, + ) { let current = self .info .as_ref() @@ -438,20 +452,29 @@ impl OpenPgpViewModel { .info .as_ref() .map(|i| { - (i.name.clone(), i.login.clone(), i.url.clone(), i.lang.clone(), i.sex) + ( + i.name.clone(), + i.login.clone(), + i.url.clone(), + i.lang.clone(), + i.sex, + ) }) - .unwrap_or((String::new(), String::new(), String::new(), String::new(), 0x39)); - let name = cx.new(|cx| { - gpui_component::input::InputState::new(window, cx).default_value(cur_name) - }); - let login = cx.new(|cx| { - gpui_component::input::InputState::new(window, cx).default_value(cur_login) - }); + .unwrap_or(( + String::new(), + String::new(), + String::new(), + String::new(), + 0x39, + )); + let name = + cx.new(|cx| gpui_component::input::InputState::new(window, cx).default_value(cur_name)); + let login = cx + .new(|cx| gpui_component::input::InputState::new(window, cx).default_value(cur_login)); let url = cx.new(|cx| gpui_component::input::InputState::new(window, cx).default_value(cur_url)); - let lang = cx.new(|cx| { - gpui_component::input::InputState::new(window, cx).default_value(cur_lang) - }); + let lang = + cx.new(|cx| gpui_component::input::InputState::new(window, cx).default_value(cur_lang)); let sex_row = OPT_SEX.iter().position(|(_, k)| *k == cur_sex).unwrap_or(0); let sex = select_state(window, cx, OPT_SEX, sex_row); let admin = admin_input(window, cx); diff --git a/src/ui/screens/piv/view.rs b/src/ui/screens/piv/view.rs index fd97a83..e3feb2f 100644 --- a/src/ui/screens/piv/view.rs +++ b/src/ui/screens/piv/view.rs @@ -6,7 +6,7 @@ use crate::ui::models::device::piv; use crate::ui::screens::piv::view_model::PivViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() @@ -31,7 +31,12 @@ fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { fn kv(label: &str, value: String, theme: &Theme) -> impl IntoElement { v_flex() .gap_1() - .child(div().text_sm().text_color(theme.muted_foreground).child(label.to_string())) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(label.to_string()), + ) .child(div().text_sm().font_medium().child(value)) } @@ -48,7 +53,10 @@ impl PivViewModel { let theme = cx.theme(); let slot = s.slot; let has_key = s.meta.is_some(); - let is_generated = s.meta.map(|m| m.origin == piv::ORIGIN_GENERATED).unwrap_or(false); + let is_generated = s + .meta + .map(|m| m.origin == piv::ORIGIN_GENERATED) + .unwrap_or(false); let status_text = match s.meta { Some(m) => format!("{} · {}", piv::algo_label(m.algo), origin_label(m.origin)), None => "Empty".to_string(), @@ -62,7 +70,9 @@ impl PivViewModel { .label($label) .ghost() .disabled(d) - .on_click(cx.listener(move |this, _, window, cx| this.$method(slot, window, cx))) + .on_click( + cx.listener(move |this, _, window, cx| this.$method(slot, window, cx)), + ) .into_any_element() }; } @@ -148,7 +158,12 @@ impl PivViewModel { v_flex() .gap_0p5() .child(div().font_medium().child(title)) - .child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)), + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(subtitle), + ), ) .child(btn) } @@ -214,11 +229,25 @@ impl Render for PivViewModel { Some(i) => { let pin = i .pin - .map(|p| format!("{}/{}{}", p.left, p.total, if p.is_default { " (default)" } else { "" })) + .map(|p| { + format!( + "{}/{}{}", + p.left, + p.total, + if p.is_default { " (default)" } else { "" } + ) + }) .unwrap_or_else(|| "—".into()); let puk = i .puk - .map(|p| format!("{}/{}{}", p.left, p.total, if p.is_default { " (default)" } else { "" })) + .map(|p| { + format!( + "{}/{}{}", + p.left, + p.total, + if p.is_default { " (default)" } else { "" } + ) + }) .unwrap_or_else(|| "—".into()); let mgm = format!( "{}{}", @@ -229,14 +258,22 @@ impl Render for PivViewModel { .grid() .grid_cols(2) .gap_4() - .child(kv("Firmware", format!("{}.{}.{}", i.version[0], i.version[1], i.version[2]), theme)) + .child(kv( + "Firmware", + format!("{}.{}.{}", i.version[0], i.version[1], i.version[2]), + theme, + )) .child(kv("Serial", i.serial.to_string(), theme)) .child(kv("PIN tries", pin, theme)) .child(kv("PUK tries", puk, theme)) .child(kv("Management key", mgm, theme)) .into_any_element() } - None => div().text_sm().text_color(theme.muted_foreground).child("Reading card…").into_any_element(), + None => div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Reading card…") + .into_any_element(), }; Card::new() .title("Card information") @@ -259,10 +296,30 @@ impl Render for PivViewModel { .child( v_flex() .gap_2() - .child(self.action_row("PIN", "Change the 6–8 digit PIV PIN", change_pin_btn, theme)) - .child(self.action_row("PUK", "Change the PIN Unblock Key", change_puk_btn, theme)) - .child(self.action_row("Unblock", "Reset a blocked PIN using the PUK", unblock_btn, theme)) - .child(self.action_row("Retry limits", "Set PIN/PUK retries (resets both to defaults)", retries_btn, theme)), + .child(self.action_row( + "PIN", + "Change the 6–8 digit PIV PIN", + change_pin_btn, + theme, + )) + .child(self.action_row( + "PUK", + "Change the PIN Unblock Key", + change_puk_btn, + theme, + )) + .child(self.action_row( + "Unblock", + "Reset a blocked PIN using the PUK", + unblock_btn, + theme, + )) + .child(self.action_row( + "Retry limits", + "Set PIN/PUK retries (resets both to defaults)", + retries_btn, + theme, + )), ); let mgm_card = Card::new() diff --git a/src/ui/screens/piv/view_model.rs b/src/ui/screens/piv/view_model.rs index 74c6770..1ee3478 100644 --- a/src/ui/screens/piv/view_model.rs +++ b/src/ui/screens/piv/view_model.rs @@ -6,12 +6,12 @@ use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; -use crate::ui::components::form::{select_state, selected_key, LabeledU8}; -use crate::ui::models::device::{piv, DeviceEvent, DeviceRepo, MgmAuth, USB_CAP_PIV}; +use crate::ui::components::form::{LabeledU8, select_state, selected_key}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, MgmAuth, USB_CAP_PIV, piv}; use gpui::*; +use gpui_component::WindowExt; use gpui_component::button::ButtonVariants; use gpui_component::select::SelectState; -use gpui_component::WindowExt; const OPT_ALGO: &[(&str, u8)] = &[ ("ECC P-256", 0x11), @@ -22,8 +22,7 @@ const OPT_ALGO: &[(&str, u8)] = &[ ("RSA-3072", 0x05), ("RSA-4096", 0x16), ]; -const OPT_PIN_POLICY: &[(&str, u8)] = - &[("Default", 0), ("Never", 1), ("Once", 2), ("Always", 3)]; +const OPT_PIN_POLICY: &[(&str, u8)] = &[("Default", 0), ("Never", 1), ("Once", 2), ("Always", 3)]; const OPT_TOUCH_POLICY: &[(&str, u8)] = &[("Default", 0), ("Never", 1), ("Always", 2), ("Cached", 3)]; const OPT_TRIES: &[(&str, u8)] = &[("3", 3), ("5", 5), ("8", 8), ("10", 10)]; @@ -142,7 +141,10 @@ impl PivViewModel { /// The stored management-key algorithm (default AES-192). fn mgm_algo(&self) -> u8 { - self.info.as_ref().map(|i| i.mgm_algo).unwrap_or(piv::ALGO_AES192) + self.info + .as_ref() + .map(|i| i.mgm_algo) + .unwrap_or(piv::ALGO_AES192) } /// Whether this card's management key is PIN-protected (ykman `--protect`). @@ -275,10 +277,8 @@ impl PivViewModel { let _ = view.update(cx, |this, cx| { this.run( move || { - DeviceRepo::piv_generate_blocking( - slot, algo, pin_pol, touch_pol, auth, - ) - .map(|_| ()) + DeviceRepo::piv_generate_blocking(slot, algo, pin_pol, touch_pol, auth) + .map(|_| ()) }, "Key generated.", status, @@ -296,9 +296,15 @@ impl PivViewModel { let ok = submit.clone(); let btn = submit.clone(); let field = |label: &str, sel: &Entity>>| { - gpui_component::v_flex().gap_1().flex_1().child(label.to_string()).child( - gpui_component::select::Select::new(sel).w_full().bg(rgb(0x222225)), - ) + gpui_component::v_flex() + .gap_1() + .flex_1() + .child(label.to_string()) + .child( + gpui_component::select::Select::new(sel) + .w_full() + .bg(rgb(0x222225)), + ) }; dialog .title(format!("Generate — {}", piv::slot_label(slot))) @@ -338,10 +344,16 @@ impl PivViewModel { // ── Export certificate (DER → PEM file) ───────────────────────────────── - pub(super) fn open_export_cert(&mut self, slot: u8, _window: &mut Window, cx: &mut Context) { - let default_dir = std::env::var("HOME").map(std::path::PathBuf::from).unwrap_or_default(); - let receiver = - cx.prompt_for_new_path(&default_dir, Some(&format!("piv-{slot:02x}.pem"))); + pub(super) fn open_export_cert( + &mut self, + slot: u8, + _window: &mut Window, + cx: &mut Context, + ) { + let default_dir = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + let receiver = cx.prompt_for_new_path(&default_dir, Some(&format!("piv-{slot:02x}.pem"))); let view = cx.entity().downgrade(); self._task = Some(cx.spawn(async move |_, cx| { let Ok(Ok(Some(path))) = receiver.await else { @@ -369,7 +381,12 @@ impl PivViewModel { // ── PIN / PUK ─────────────────────────────────────────────────────────── - pub(super) fn open_change_pin(&mut self, is_puk: bool, window: &mut Window, cx: &mut Context) { + pub(super) fn open_change_pin( + &mut self, + is_puk: bool, + window: &mut Window, + cx: &mut Context, + ) { let title = if is_puk { "Change PUK" } else { "Change PIN" }; self.two_secret_dialog( title, @@ -384,7 +401,11 @@ impl PivViewModel { DeviceRepo::piv_change_pin_blocking(cur, new) } }, - if is_puk { "PUK changed." } else { "PIN changed." }, + if is_puk { + "PUK changed." + } else { + "PIN changed." + }, ); } @@ -469,7 +490,12 @@ impl PivViewModel { // ── Delete certificate (mgmt-gated) ───────────────────────────────────── - pub(super) fn open_delete_cert(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + pub(super) fn open_delete_cert( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { let mgm_algo = self.mgm_algo(); let mgm = self.mgm_input(window, cx); let protected = self.mgm_protected(); @@ -500,7 +526,9 @@ impl PivViewModel { let btn = submit.clone(); dialog .title(format!("Delete certificate — {}", piv::slot_label(slot))) - .child("Clears this slot's certificate (the key stays). Requires the management key.") + .child( + "Clears this slot's certificate (the key stays). Requires the management key.", + ) .child( gpui_component::v_flex() .gap_2() @@ -574,9 +602,15 @@ impl PivViewModel { let ok = submit.clone(); let btn = submit.clone(); let field = |label: &str, sel: &Entity>>| { - gpui_component::v_flex().gap_1().flex_1().child(label.to_string()).child( - gpui_component::select::Select::new(sel).w_full().bg(rgb(0x222225)), - ) + gpui_component::v_flex() + .gap_1() + .flex_1() + .child(label.to_string()) + .child( + gpui_component::select::Select::new(sel) + .w_full() + .bg(rgb(0x222225)), + ) }; dialog .title("Set PIN Retries") @@ -649,7 +683,8 @@ impl PivViewModel { let view = view.clone(); std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { let notify = |cx: &mut App, msg: &str| { - let _ = view.update(cx, |_, cx| cx.emit(PivEvent::Notification(msg.to_string()))); + let _ = + view.update(cx, |_, cx| cx.emit(PivEvent::Notification(msg.to_string()))); }; let Some(current) = resolve_mgm_auth(&cur, protected, cur_algo, &view, cx) else { return; @@ -657,16 +692,19 @@ impl PivViewModel { let new_algo = selected_key(&algo_sel, OPT_MGM_ALGO, cx); let new_key = match hex::decode(new.read(cx).text().to_string().trim()) { Ok(k) if piv_key_len_ok(new_algo, k.len()) => k, - _ => return notify(cx, "New key length must match the algorithm (16/24/32 bytes)"), + _ => { + return notify( + cx, + "New key length must match the algorithm (16/24/32 bytes)", + ); + } }; let touch = selected_key(&touch_sel, OPT_MGM_TOUCH, cx) == 1; window.close_dialog(cx); let status = dialog::open_status_dialog("Changing Management Key", window, cx); let _ = view.update(cx, |this, cx| { this.run( - move || { - DeviceRepo::piv_set_mgm_blocking(current, new_algo, new_key, touch) - }, + move || DeviceRepo::piv_set_mgm_blocking(current, new_algo, new_key, touch), "Management key changed.", status, cx, @@ -741,7 +779,12 @@ impl PivViewModel { // ── Import certificate / key (file → management-key dialog) ────────────── - pub(super) fn open_import_cert(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + pub(super) fn open_import_cert( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { let handle = window.window_handle(); let receiver = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -758,22 +801,33 @@ impl PivViewModel { return; }; let Ok(bytes) = std::fs::read(&path) else { - let _ = view.update(cx, |_, cx| cx.emit(PivEvent::Notification("Could not read file".into()))); + let _ = view.update(cx, |_, cx| { + cx.emit(PivEvent::Notification("Could not read file".into())) + }); return; }; let Some(der) = cert_pem_to_der(&bytes) else { let _ = view.update(cx, |_, cx| { - cx.emit(PivEvent::Notification("Not a valid PEM/DER certificate".into())) + cx.emit(PivEvent::Notification( + "Not a valid PEM/DER certificate".into(), + )) }); return; }; let _ = cx.update_window(handle, |_, window, cx| { - let _ = view.update(cx, |this, cx| this.open_mgm_import(slot, der, false, window, cx)); + let _ = view.update(cx, |this, cx| { + this.open_mgm_import(slot, der, false, window, cx) + }); }); })); } - pub(super) fn open_import_key(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + pub(super) fn open_import_key( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { let handle = window.window_handle(); let receiver = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -790,11 +844,15 @@ impl PivViewModel { return; }; let Ok(bytes) = std::fs::read(&path) else { - let _ = view.update(cx, |_, cx| cx.emit(PivEvent::Notification("Could not read file".into()))); + let _ = view.update(cx, |_, cx| { + cx.emit(PivEvent::Notification("Could not read file".into())) + }); return; }; let _ = cx.update_window(handle, |_, window, cx| { - let _ = view.update(cx, |this, cx| this.open_mgm_import(slot, bytes, true, window, cx)); + let _ = view.update(cx, |this, cx| { + this.open_mgm_import(slot, bytes, true, window, cx) + }); }); })); } @@ -822,7 +880,11 @@ impl PivViewModel { let file = file.clone(); window.close_dialog(cx); let status = dialog::open_status_dialog( - if is_key { "Importing Key" } else { "Importing Certificate" }, + if is_key { + "Importing Key" + } else { + "Importing Certificate" + }, window, cx, ); @@ -850,7 +912,11 @@ impl PivViewModel { let ok = submit.clone(); let btn = submit.clone(); dialog - .title(if is_key { "Import Key" } else { "Import Certificate" }) + .title(if is_key { + "Import Key" + } else { + "Import Certificate" + }) .child("Enter the management key to authorise the import.") .child( gpui_component::v_flex() @@ -881,9 +947,13 @@ impl PivViewModel { // ── Attestation (export the attestation cert of a generated key) ───────── pub(super) fn open_attest(&mut self, slot: u8, _window: &mut Window, cx: &mut Context) { - let default_dir = std::env::var("HOME").map(std::path::PathBuf::from).unwrap_or_default(); - let receiver = - cx.prompt_for_new_path(&default_dir, Some(&format!("piv-{slot:02x}-attestation.pem"))); + let default_dir = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default(); + let receiver = cx.prompt_for_new_path( + &default_dir, + Some(&format!("piv-{slot:02x}-attestation.pem")), + ); let view = cx.entity().downgrade(); self._task = Some(cx.spawn(async move |_, cx| { let Ok(Ok(Some(path))) = receiver.await else { @@ -908,7 +978,12 @@ impl PivViewModel { // ── Delete key (mgmt-gated) ────────────────────────────────────────────── - pub(super) fn open_delete_key(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + pub(super) fn open_delete_key( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { let mgm_algo = self.mgm_algo(); let mgm = self.mgm_input(window, cx); let protected = self.mgm_protected(); @@ -986,7 +1061,9 @@ impl PivViewModel { let dst = selected_key(&dst_sel, OPT_SLOTS, cx); if dst == src { let _ = view.update(cx, |_, cx| { - cx.emit(PivEvent::Notification("Choose a different destination slot".into())); + cx.emit(PivEvent::Notification( + "Choose a different destination slot".into(), + )); }); return; } @@ -1072,7 +1149,9 @@ fn cert_pem_to_der(input: &[u8]) -> Option> { let end = body.find("-----END")?; let b64: String = body[..end].chars().filter(|c| !c.is_whitespace()).collect(); use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(b64.as_bytes()).ok() + base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .ok() } else if input.first() == Some(&0x30) { Some(input.to_vec()) } else { diff --git a/src/ui/screens/slots/program_form.rs b/src/ui/screens/slots/program_form.rs index 14f4dc4..bee84bc 100644 --- a/src/ui/screens/slots/program_form.rs +++ b/src/ui/screens/slots/program_form.rs @@ -9,14 +9,14 @@ use crate::error::PFError; use crate::ui::components::dialog; -use crate::ui::components::form::{select_state, selected_key, LabeledU8}; -use crate::ui::models::device::{otp, DeviceRepo}; +use crate::ui::components::form::{LabeledU8, select_state, selected_key}; +use crate::ui::models::device::{DeviceRepo, otp}; use crate::ui::screens::slots::view_model::{SlotsEvent, SlotsViewModel}; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Input, InputState}; use gpui_component::select::{Select, SelectEvent, SelectState}; -use gpui_component::{h_flex, v_flex, WindowExt}; +use gpui_component::{WindowExt, h_flex, v_flex}; const OPT_TYPE: &[(&str, u8)] = &[ ("Challenge-response", 0), @@ -129,9 +129,9 @@ pub(super) fn open(slot: u8, window: &mut Window, cx: &mut Context, msg: &str) { - let _ = self - .view - .update(cx, |_, cx| cx.emit(SlotsEvent::Notification(msg.to_string()))); + let _ = self.view.update(cx, |_, cx| { + cx.emit(SlotsEvent::Notification(msg.to_string())) + }); } /// Close the form dialog, show a status dialog, and run the program op. @@ -163,11 +163,11 @@ impl ProgramSlotForm { _ => return self.notify(cx, "Access codes must be hex, ≤ 6 bytes"), }; let slot = self.slot; - let hex_secret = |s: &Entity, cx: &mut Context| { - match hex::decode(s.read(cx).text().to_string().trim()) { - Ok(b) if !b.is_empty() && b.len() <= 20 => Some(b), - _ => None, - } + let hex_secret = |s: &Entity, cx: &mut Context| match hex::decode( + s.read(cx).text().to_string().trim(), + ) { + Ok(b) if !b.is_empty() && b.len() <= 20 => Some(b), + _ => None, }; match ty { @@ -178,7 +178,9 @@ impl ProgramSlotForm { self.dispatch( window, cx, - move || DeviceRepo::otp_program_chalresp_blocking(slot, bytes, touch, new_a, cur_a), + move || { + DeviceRepo::otp_program_chalresp_blocking(slot, bytes, touch, new_a, cur_a) + }, "Challenge-response programmed.".into(), ); } @@ -199,7 +201,8 @@ impl ProgramSlotForm { } 2 => { let scancodes = - match otp::ascii_to_scancodes(self.password.read(cx).text().to_string().trim()) { + match otp::ascii_to_scancodes(self.password.read(cx).text().to_string().trim()) + { Some(s) if !s.is_empty() => s, _ => return self.notify(cx, "Password must be ASCII, 1–38 characters"), }; @@ -218,17 +221,26 @@ impl ProgramSlotForm { let public = match otp::modhex_decode(self.yk_public.read(cx).text().to_string().trim()) { Some(p) if !p.is_empty() && p.len() <= 16 => p, - _ => return self.notify(cx, "Public ID must be modhex (≤ 16 bytes) — use Generate"), + _ => { + return self + .notify(cx, "Public ID must be modhex (≤ 16 bytes) — use Generate"); + } }; let private: [u8; 6] = match hex::decode(self.yk_private.read(cx).text().to_string().trim()) { Ok(b) if b.len() == 6 => b.try_into().unwrap(), - _ => return self.notify(cx, "Private ID must be 6 bytes of hex — use Generate"), + _ => { + return self + .notify(cx, "Private ID must be 6 bytes of hex — use Generate"); + } }; let key: [u8; 16] = match hex::decode(self.yk_key.read(cx).text().to_string().trim()) { Ok(b) if b.len() == 16 => b.try_into().unwrap(), - _ => return self.notify(cx, "Secret key must be 16 bytes of hex — use Generate"), + _ => { + return self + .notify(cx, "Secret key must be 16 bytes of hex — use Generate"); + } }; let msg = format!( "Yubico OTP programmed. Register with a validation server:\nPublic ID: {}\nPrivate ID: {}\nKey: {}", @@ -312,7 +324,8 @@ impl Render for ProgramSlotForm { let secret = self.secret.clone(); move |window, cx| { if let Ok(s) = otp::random_secret() { - secret.update(cx, |st, cx| st.set_value(hex::encode(s), window, cx)); + secret + .update(cx, |st, cx| st.set_value(hex::encode(s), window, cx)); } } }, @@ -330,7 +343,8 @@ impl Render for ProgramSlotForm { let secret = self.secret.clone(); move |window, cx| { if let Ok(s) = otp::random_secret() { - secret.update(cx, |st, cx| st.set_value(hex::encode(s), window, cx)); + secret + .update(cx, |st, cx| st.set_value(hex::encode(s), window, cx)); } } }, @@ -369,10 +383,10 @@ impl Render for ProgramSlotForm { .child(labeled_input("Private ID (hex, 6 bytes)", &self.yk_private)) .child(labeled_input("Secret key (hex, 16 bytes)", &self.yk_key)) .child( - h_flex().justify_between().items_center().child(labeled_select( - "Append Enter", - &self.append_sel, - )), + h_flex() + .justify_between() + .items_center() + .child(labeled_select("Append Enter", &self.append_sel)), ) .child( Button::new("gen-yubico") diff --git a/src/ui/screens/slots/view.rs b/src/ui/screens/slots/view.rs index 4ad302d..8adece7 100644 --- a/src/ui/screens/slots/view.rs +++ b/src/ui/screens/slots/view.rs @@ -6,7 +6,7 @@ use crate::ui::models::device::otp; use crate::ui::screens::slots::view_model::SlotsViewModel; use gpui::*; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme}; +use gpui_component::{ActiveTheme, Disableable, Icon, StyledExt, Theme, h_flex, v_flex}; fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement { v_flex() diff --git a/src/ui/screens/slots/view_model.rs b/src/ui/screens/slots/view_model.rs index 1108074..7df412d 100644 --- a/src/ui/screens/slots/view_model.rs +++ b/src/ui/screens/slots/view_model.rs @@ -5,10 +5,10 @@ use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; use crate::ui::components::dialog::StatusContent; -use crate::ui::models::device::{otp, DeviceEvent, DeviceRepo, USB_CAP_OTP}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo, USB_CAP_OTP, otp}; use gpui::*; -use gpui_component::button::ButtonVariants; use gpui_component::WindowExt; +use gpui_component::button::ButtonVariants; /// Slots screen state and OTP slot operations. pub struct SlotsViewModel { @@ -151,7 +151,9 @@ impl SlotsViewModel { let acc_input = acc_input.clone(); let view = view.clone(); std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { - let Some(acc) = current_acc(&acc_input, &view, cx) else { return }; + let Some(acc) = current_acc(&acc_input, &view, cx) else { + return; + }; window.close_dialog(cx); let status = dialog::open_status_dialog("Swap Slots", window, cx); let _ = view.update(cx, |this, cx| this.execute_swap(acc, status, cx)); @@ -212,7 +214,12 @@ impl SlotsViewModel { ); } - pub(super) fn open_delete_dialog(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + pub(super) fn open_delete_dialog( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { let acc_input = cx.new(|cx| { gpui_component::input::InputState::new(window, cx) .placeholder("Access code (hex, if the slot is protected)") @@ -222,7 +229,9 @@ impl SlotsViewModel { let acc_input = acc_input.clone(); let view = view.clone(); std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { - let Some(acc) = current_acc(&acc_input, &view, cx) else { return }; + let Some(acc) = current_acc(&acc_input, &view, cx) else { + return; + }; window.close_dialog(cx); let status = dialog::open_status_dialog("Delete Slot", window, cx); let _ = view.update(cx, |this, cx| this.execute_delete(slot, acc, status, cx)); @@ -326,7 +335,12 @@ impl SlotsViewModel { })); } - pub(super) fn open_test_dialog(&mut self, slot: u8, window: &mut Window, cx: &mut Context) { + pub(super) fn open_test_dialog( + &mut self, + slot: u8, + window: &mut Window, + cx: &mut Context, + ) { let challenge = cx.new(|cx| { gpui_component::input::InputState::new(window, cx).placeholder("Challenge (hex)") }); @@ -339,7 +353,9 @@ impl SlotsViewModel { Ok(b) if !b.is_empty() => b, _ => { let _ = view.update(cx, |_, cx| { - cx.emit(SlotsEvent::Notification("Enter a valid hex challenge".into())); + cx.emit(SlotsEvent::Notification( + "Enter a valid hex challenge".into(), + )); }); return; } From c71d2b082004dfc49b8d92014bd028614cc75fc3 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Tue, 28 Jul 2026 15:21:17 +0530 Subject: [PATCH 5/7] chore(fmt): run cargo fmt with latest rustc --- src/ui/screens/slots/program_form.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/screens/slots/program_form.rs b/src/ui/screens/slots/program_form.rs index bee84bc..c251d00 100644 --- a/src/ui/screens/slots/program_form.rs +++ b/src/ui/screens/slots/program_form.rs @@ -222,8 +222,10 @@ impl ProgramSlotForm { match otp::modhex_decode(self.yk_public.read(cx).text().to_string().trim()) { Some(p) if !p.is_empty() && p.len() <= 16 => p, _ => { - return self - .notify(cx, "Public ID must be modhex (≤ 16 bytes) — use Generate"); + return self.notify( + cx, + "Public ID must be modhex (≤ 16 bytes) — use Generate", + ); } }; let private: [u8; 6] = From f3b0dfe5554bc5d6ac080af38c2170634e955f37 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Tue, 28 Jul 2026 15:34:57 +0530 Subject: [PATCH 6/7] fix: clippy issues on latest rustc --- src/hal/applets/otp.rs | 7 +++++-- src/hal/fido/audit.rs | 2 +- src/ui/mod.rs | 3 +++ src/ui/screens/audit/view_model.rs | 3 ++- src/ui/screens/backup/view_model.rs | 3 ++- src/ui/screens/config/view.rs | 6 ++---- src/ui/screens/config/view_model.rs | 2 ++ src/ui/screens/home/view.rs | 4 ++-- src/ui/screens/lock/view_model.rs | 5 +++-- src/ui/screens/piv/view_model.rs | 4 +++- 10 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/hal/applets/otp.rs b/src/hal/applets/otp.rs index 8d0f15d..faf320f 100644 --- a/src/hal/applets/otp.rs +++ b/src/hal/applets/otp.rs @@ -261,7 +261,7 @@ const MODHEX: &[u8; 16] = b"cbdefghijklnrtuv"; /// Decode a modhex string (Yubico's keyboard-safe hex) into bytes. pub fn modhex_decode(s: &str) -> Option> { let s = s.trim(); - if s.is_empty() || s.len() % 2 != 0 { + if s.is_empty() || !s.len().is_multiple_of(2) { return None; } let bytes = s.as_bytes(); @@ -356,8 +356,11 @@ pub fn random_secret() -> Result<[u8; SECRET_LEN], PFError> { Ok(s) } +/// (public id, private id, AES key) for a Yubico OTP slot. +pub type YubicoKeyMaterial = ([u8; 6], [u8; 6], [u8; 16]); + /// Random (public id, private id, AES key) for a self-generated Yubico OTP slot. -pub fn random_yubico() -> Result<([u8; 6], [u8; 6], [u8; 16]), PFError> { +pub fn random_yubico() -> Result { let mut buf = [0u8; 28]; SystemRandom::new() .fill(&mut buf) diff --git a/src/hal/fido/audit.rs b/src/hal/fido/audit.rs index df19cc5..c6faf15 100644 --- a/src/hal/fido/audit.rs +++ b/src/hal/fido/audit.rs @@ -171,7 +171,7 @@ pub fn build_journal( entries_bytes: &[u8], ) -> Result { let expected = (seq_next.saturating_sub(start) as usize) * ENTRY_LEN; - if entries_bytes.len() % ENTRY_LEN != 0 || entries_bytes.len() != expected { + if !entries_bytes.len().is_multiple_of(ENTRY_LEN) || entries_bytes.len() != expected { return Err("export length does not match the window — corrupt journal?".into()); } Ok(AuditJournal { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 94d0a08..f1b2461 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -154,3 +154,6 @@ pub mod colors; pub mod components; pub mod models; pub mod screens; + +/// Closure type shared across dialog patterns in screen view-models. +pub(crate) type DialogSubmit = std::rc::Rc; diff --git a/src/ui/screens/audit/view_model.rs b/src/ui/screens/audit/view_model.rs index 142a1e6..941d560 100644 --- a/src/ui/screens/audit/view_model.rs +++ b/src/ui/screens/audit/view_model.rs @@ -1,6 +1,7 @@ //! View model for the Audit screen — export and verify the device's //! tamper-evident security journal. +use crate::ui::DialogSubmit; use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; @@ -320,7 +321,7 @@ impl AuditViewModel { body: &'static str, pin: Entity, extra: Option<(&'static str, Entity)>, - submit: std::rc::Rc, + submit: DialogSubmit, window: &mut Window, cx: &mut Context, ) { diff --git a/src/ui/screens/backup/view_model.rs b/src/ui/screens/backup/view_model.rs index fef039b..55d0db2 100644 --- a/src/ui/screens/backup/view_model.rs +++ b/src/ui/screens/backup/view_model.rs @@ -1,5 +1,6 @@ //! View model for the Backup screen — wallet-style FIDO seed export/restore. +use crate::ui::DialogSubmit; use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; @@ -265,7 +266,7 @@ impl BackupViewModel { pin: Entity, extra: Option<(&'static str, Entity)>, action: (&'static str, ButtonVariant), - submit: std::rc::Rc, + submit: DialogSubmit, window: &mut Window, cx: &mut Context, ) { diff --git a/src/ui/screens/config/view.rs b/src/ui/screens/config/view.rs index 86a4fca..25f708f 100644 --- a/src/ui/screens/config/view.rs +++ b/src/ui/screens/config/view.rs @@ -310,9 +310,7 @@ impl ConfigViewModel { let inc_bright_listener = cx.listener(move |this, _, _, cx| { let b = this.led_status_brightness[i]; - this.led_status_brightness[i] = b - .saturating_add(LED_BRIGHTNESS_STEP) - .min(LED_BRIGHTNESS_MAX); + this.led_status_brightness[i] = b.saturating_add(LED_BRIGHTNESS_STEP); cx.notify(); }); @@ -373,7 +371,7 @@ impl ConfigViewModel { .active(rgb(0x3f3f46).into()) .border(theme.border), ) - .disabled(is_fido || brightness_val >= LED_BRIGHTNESS_MAX) + .disabled(is_fido || brightness_val == LED_BRIGHTNESS_MAX) .on_click(inc_bright_listener), ), ), diff --git a/src/ui/screens/config/view_model.rs b/src/ui/screens/config/view_model.rs index 13b06b5..4331945 100644 --- a/src/ui/screens/config/view_model.rs +++ b/src/ui/screens/config/view_model.rs @@ -567,6 +567,8 @@ impl ConfigViewModel { } } + // TODO: refactor into parameter struct to remove this clippy escape + #[allow(clippy::too_many_arguments)] pub(super) fn write_config_to_device( &mut self, phy: Option, diff --git a/src/ui/screens/home/view.rs b/src/ui/screens/home/view.rs index d08140f..369a048 100644 --- a/src/ui/screens/home/view.rs +++ b/src/ui/screens/home/view.rs @@ -50,9 +50,9 @@ impl HomeViewModel { /// Human-readable flash chip size. RP2350 boards are whole-MB (2/4/16 MB). fn format_flash_size(bytes: u32) -> String { const MB: u32 = 1024 * 1024; - if bytes >= MB && bytes % MB == 0 { + if bytes >= MB && bytes.is_multiple_of(MB) { format!("{} MB", bytes / MB) - } else if bytes >= 1024 && bytes % 1024 == 0 { + } else if bytes >= 1024 && bytes.is_multiple_of(1024) { format!("{} KB", bytes / 1024) } else { format!("{} B", bytes) diff --git a/src/ui/screens/lock/view_model.rs b/src/ui/screens/lock/view_model.rs index f0d0a35..b497bf2 100644 --- a/src/ui/screens/lock/view_model.rs +++ b/src/ui/screens/lock/view_model.rs @@ -1,5 +1,6 @@ //! View model for the Lock screen — at-rest soft-lock of the FIDO seed. +use crate::ui::DialogSubmit; use crate::ui::app::AppModels; use crate::ui::components::applet_gate::AppletGate; use crate::ui::components::dialog; @@ -281,7 +282,7 @@ impl LockViewModel { extra: Option<(&'static str, Entity)>, pin: Entity, action: (&'static str, ButtonVariant), - submit: std::rc::Rc, + submit: DialogSubmit, window: &mut Window, cx: &mut Context, ) { @@ -329,7 +330,7 @@ impl LockViewModel { body: &'static str, phrase: Entity, action: (&'static str, ButtonVariant), - submit: std::rc::Rc, + submit: DialogSubmit, window: &mut Window, cx: &mut Context, ) { diff --git a/src/ui/screens/piv/view_model.rs b/src/ui/screens/piv/view_model.rs index 1ee3478..89a301f 100644 --- a/src/ui/screens/piv/view_model.rs +++ b/src/ui/screens/piv/view_model.rs @@ -416,12 +416,14 @@ impl PivViewModel { "New PIN", window, cx, - |puk, new| DeviceRepo::piv_unblock_pin_blocking(puk, new), + DeviceRepo::piv_unblock_pin_blocking, "PIN unblocked.", ); } /// A two-masked-field dialog (current/new or puk/new) running a blocking op. + // TODO: refactor into parameter struct to remove this clippy escape + #[allow(clippy::too_many_arguments)] fn two_secret_dialog( &mut self, title: &'static str, From 0763cc625b0da4c6adf8638fe606665271fa6f47 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Tue, 28 Jul 2026 16:04:11 +0530 Subject: [PATCH 7/7] fix: rustdoc issues --- src/hal/applets/oath.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hal/applets/oath.rs b/src/hal/applets/oath.rs index 6508b48..64dc4fe 100644 --- a/src/hal/applets/oath.rs +++ b/src/hal/applets/oath.rs @@ -1,7 +1,7 @@ //! YKOATH (Yubico OATH) applet client — TOTP/HOTP account management. //! //! Speaks the standard Yubico OATH wire protocol over a -//! [`CcidSession`](crate::hal::transport::ccid::CcidSession), so it is +//! [`CcidSession`], so it is //! firmware-agnostic (RS-Key emulates the same AID and command set). The device //! holds the secrets and computes the codes; the host only frames commands, //! parses responses, and — for password-protected devices — derives the access