From de154183c9f3c449286280ccd7fbde44a053fd48 Mon Sep 17 00:00:00 2001 From: Maxim Muravev Date: Sat, 25 Jul 2026 13:54:59 +0300 Subject: [PATCH] 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,