mirror of
https://github.com/librekeys/picoforge.git
synced 2026-07-28 08:01:19 -07:00
merge: PicoApp Func parity - #111
This commit is contained in:
Generated
+39
@@ -531,6 +531,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bip39"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc"
|
||||
dependencies = [
|
||||
"bitcoin_hashes",
|
||||
"serde",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -552,6 +563,15 @@ version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitcoin_hashes"
|
||||
version = "0.14.101"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2"
|
||||
dependencies = [
|
||||
"hex-conservative",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -2621,6 +2641,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-conservative"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hexf-parse"
|
||||
version = "0.2.1"
|
||||
@@ -4232,6 +4261,7 @@ dependencies = [
|
||||
"aes 0.9.1",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"bip39",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"cbc 0.2.1",
|
||||
@@ -6520,6 +6550,15 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
|
||||
@@ -31,6 +31,7 @@ base64 = "0.22" # For PEM encoding of DER certificates
|
||||
ring = "0.17" # For signing fido2 messages with pin token
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
bip39 = "2" # For rendering the FIDO seed backup as a 24-word mnemonic
|
||||
|
||||
# For Application UI:
|
||||
gpui = { version = "0.2.2", features = [] }
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! 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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
//! 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().is_multiple_of(ENTRY_LEN) || 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::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair, KeyPair};
|
||||
|
||||
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).
|
||||
|
||||
+554
-9
File diff suppressed because it is too large
Load Diff
+150
-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,114 @@ 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;
|
||||
|
||||
|
||||
+586
-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,158 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user