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.
This commit is contained in:
Maxim Muravev
2026-07-25 14:17:36 +03:00
parent 4ce855cb7f
commit de154183c9
23 changed files with 5305 additions and 31 deletions
+180
View File
@@ -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<u8>,
pub le: Option<u16>,
}
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<u8> {
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<u8> {
((self.0 & 0xFF00) == 0x6100).then_some((self.0 & 0xFF) as u8)
}
/// `6Cxx` → resend the command with `Le = xx`.
pub fn wrong_le(self) -> Option<u8> {
((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<u8> {
((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);
}
}
+153
View File
@@ -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<Self::Item> {
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<u32> {
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<usize> {
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<u8>, 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<u8>, 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);
}
}
+74
View File
@@ -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,
}
File diff suppressed because it is too large Load Diff
+462
View File
@@ -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<PgpKey>,
}
/// 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<Vec<u8>> {
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, PFError> {
CcidSession::open(OPENPGP_AID)
}
fn get_data(session: &CcidSession, tag: u16) -> Result<Vec<u8>, 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<PgpInfo, PFError> {
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<Vec<u8>, 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 <aid>, 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");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+260
View File
@@ -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<AuditEntry>,
}
/// 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<bool>,
}
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<AuditEntry> {
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<AuditJournal, String> {
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<u8> {
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);
}
}
+135
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<String, String> {
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());
}
}
+38
View File
@@ -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).
+541 -9
View File
File diff suppressed because it is too large Load Diff
+140 -9
View File
@@ -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<Vec<u8>, PFError>;
fn rs_key_config_read(&self, target: u8) -> Result<(Vec<u8>, 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<Value>,
pin: Option<&str>,
) -> Result<(u8, Option<Value>), 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<Value>,
) -> 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<Vec<u8>, PFError> {
fn rs_key_config_read(&self, target: u8) -> Result<(Vec<u8>, 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::<Value>(&resp) {
Ok(Value::Map(m)) => match m.get(&Value::Integer(1)) {
Some(Value::Bytes(b)) => Ok(b.clone()),
_ => Err(PFError::Device(
"CONFIG_READ response missing blob (key 1)".into(),
)),
},
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<Value>,
pin: Option<&str>,
) -> Result<(u8, Option<Value>), PFError> {
let params_bytes = match &params {
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(&params_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::<Value>(&resp[1..]).ok()
} else {
None
};
Ok((status, map))
}
fn authconfig_vendor(
&self,
pin_token: &[u8],
vendor_id: u64,
param: Option<Value>,
) -> 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
+110
View File
@@ -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<OathFeatures> {
None
}
fn otp(&self) -> Option<OtpFeatures> {
None
}
fn piv(&self) -> Option<PivFeatures> {
None
}
fn openpgp(&self) -> Option<OpenPgpFeatures> {
None
}
}
impl AppletProfile for RSKeyFirmware {
fn oath(&self) -> Option<OathFeatures> {
// 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<OtpFeatures> {
// 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<PivFeatures> {
Some(PivFeatures {
generate: true,
import_cert: true,
attestation: true,
retired_slots: true,
})
}
fn openpgp(&self) -> Option<OpenPgpFeatures> {
// 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<OathFeatures> {
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<OtpFeatures> {
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<PivFeatures> {
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<OpenPgpFeatures> {
match self {
Self::PicoFido(fw) => fw.openpgp(),
Self::RSKey(fw) => fw.openpgp(),
}
}
}
+1
View File
@@ -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;
+583 -3
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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;
+127
View File
@@ -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<OffboardStep>,
pub signed: bool,
pub fingerprint: Option<String>,
pub signed_head: Option<String>,
pub signature: Option<String>,
pub pubkey: Option<String>,
}
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<String>| 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"));
}
}
+3
View File
@@ -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,
}
}
+46 -10
View File
@@ -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::<BigEndian>().unwrap_or(0);
let used = cursor.read_u32::<BigEndian>().unwrap_or(0);
let total = cursor.read_u32::<BigEndian>().unwrap_or(0);
// NOTE: captured but currently unused variables
let _nfiles = cursor.read_u32::<BigEndian>().unwrap_or(0);
let _chip_size = cursor.read_u32::<BigEndian>().unwrap_or(0);
let nfiles = cursor.read_u32::<BigEndian>().unwrap_or(0);
let chip_size = cursor.read_u32::<BigEndian>().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}",
+139
View File
@@ -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<u8>,
}
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<Self, PFError> {
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<u8>, 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<Vec<u8>, 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<Vec<u8>, PFError> {
self.transceive_paged(apdu, INS_SEND_REMAINING)
}
fn transceive_paged(&self, apdu: &Apdu, continue_ins: u8) -> Result<Vec<u8>, 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<Vec<u8>, 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)
}
}

Some files were not shown because too many files have changed in this diff Show More