mirror of
https://github.com/librekeys/picoforge.git
synced 2026-07-28 08:01:19 -07:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+140
-9
@@ -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 ¶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::<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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user