ctap2.3: PC + LPC infrastructure for CTAP 2.3 / ML-DSA-44 work

This commit is contained in:
Emanuele Cesena
2026-05-15 14:58:14 +02:00
parent 45ed78d8cf
commit 93a3e352d9
46 changed files with 7908 additions and 385 deletions
Generated
+657 -156
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -19,7 +19,7 @@ default-members = [
resolver = "2"
[workspace.package]
version = "2.2281.0"
version = "2.2362.0"
edition = "2021"
authors = [
"Nicolas Stalder <n@stalder.io>",
@@ -47,6 +47,7 @@ lpc55-hal = "0.5.0"
micromath = "2"
embedded-time = "0.12"
embedded-hal = { version = "0.2.5", features = ["unproven"] }
spi-memory = "0.2"
# ── Logging / debug ───────────────────────────────────────────────────────────
defmt = "1.0.1"
@@ -78,7 +79,7 @@ chacha20 = { version = "0.7", features = ["rng"] }
# ── APDU / ISO 7816 stack ─────────────────────────────────────────────────────
apdu-dispatch = "0.4"
ctaphid-dispatch = "0.4"
ctap-types = "0.5"
ctap-types = "=0.6.0-rc.4"
iso7816 = "0.2"
serde_cbor = { version = "0.11", default-features = false, features = ["std"] }
@@ -86,8 +87,8 @@ serde_cbor = { version = "0.11", default-features = false, features = ["st
littlefs2 = { version = "0.7", features = ["c-stubs"] }
# ── Trussed core ─────────────────────────────────────────────────────────────
trussed = "0.1"
trussed-core = "0.2"
trussed = { version = "0.1", features = ["aes256-gcm"] }
trussed-core = { version = "0.2", features = ["aes256-gcm"] }
# ── Trussed extensions ────────────────────────────────────────────────────────
trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging.git", tag = "v0.4.0", features = ["hkdf", "fs-info", "manage", "chunked", "hpke", "wrap-key-to-file"] }
@@ -102,11 +103,10 @@ trussed-auth-backend = { git = "https://github.com/trussed-dev/trussed-auth"
# ── Applications ─────────────────────────────────────────────────────────────
admin-app = "0.1"
fido-authenticator = { version = "0.3", features = ["dispatch"] }
oath-authenticator = { version = "0.1", features = ["apdu-dispatch"] }
fido-authenticator = { version = "=0.4.0-rc.3", features = ["dispatch", "chunked", "credential-id-format-v2"] }
salty = "0.3"
secrets-app = { git = "https://github.com/leetronics/trussed-secrets-app", branch = "fix-pin-protection-isolation", features = ["apdu-dispatch", "ctaphid", "log-all"] }
piv-authenticator = { git = "https://github.com/trussed-dev/piv-authenticator", tag = "v0.6.0", features = ["apdu-dispatch"] }
piv-authenticator = { git = "https://github.com/0x0ece/piv-authenticator", rev = "cd93aee", features = ["apdu-dispatch"] }
opcard = { git = "https://github.com/Nitrokey/opcard-rs", tag = "v1.7.0", features = ["apdu-dispatch", "delog"] }
# ── PC test-only dependencies ─────────────────────────────────────────────────
@@ -132,7 +132,10 @@ opt-level = 2
# ── Patches ───────────────────────────────────────────────────────────────────
[patch.crates-io]
trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "0f8df68be879acdde1f8cf428c11e5d29692a47b" }
ctap-types = { git = "https://github.com/0x0ece/ctap-types", rev = "1056c78b06fddc1010afebf549434ac097a6db42" }
trussed = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" }
trussed-core = { git = "https://github.com/0x0ece/trussed", rev = "a1b33c46eb16ef7fa5f6324f7c099ff339fe21e7" }
fido-authenticator = { git = "https://github.com/0x0ece/fido-authenticator", rev = "015c310541eb2e977e6efa6167a149f3c3b295da" }
admin-app = { git = "https://github.com/Nitrokey/admin-app.git", tag = "v0.1.0-nitrokey.21" }
# Force all crates to use littlefs2-core 0.1.2 which has heapless-bytes05 support
littlefs2-core = { git = "https://github.com/trussed-dev/littlefs2.git", tag = "core-0.1.2" }
+1
View File
@@ -40,3 +40,4 @@ check-clippy:
cd runners/lpc55 && cargo clippy --release --features board-lpcxpresso55 -- -D warnings
cd runners/lpc55 && cargo clippy --release --features board-solo2 -- -D warnings
cd runners/lpc55 && cargo clippy --release --features board-lpcxpresso55,provisioner-app,admin-app,provisioner-app/test-attestation -- -D warnings
+13 -3
View File
@@ -44,7 +44,6 @@ apdu-dispatch .workspace = true
ctaphid-dispatch .workspace = true
ctap-types .workspace = true
fido-authenticator = { workspace = true, optional = true }
oath-authenticator = { workspace = true, optional = true }
# OATH/TOTP/HOTP support via trussed-secrets-app (Nitrokey's implementation, Yubico OATH AID)
# Using fork with PIN protection key isolation fix backported to v0.14
secrets-app = { workspace = true, optional = true }
@@ -73,14 +72,14 @@ littlefs2.workspace = true
[features]
# ndef-app is an annoyance on some mobile platforms
default = ["admin-app", "fido-authenticator", "ndef-app", "oath", "piv-authenticator", "opcard"]
default = ["admin-app", "fido-authenticator", "ndef-app", "oath", "opcard", "piv-authenticator"]
# OATH/TOTP/HOTP via secrets-app (replaces incompatible oath-authenticator 0.1)
oath = ["dep:secrets-app"]
# develop = ["no-encrypted-storage", "no-buttons", "no-reset-time-window"]
# develop = ["no-encrypted-storage", "no-reset-time-window"]
# develop = ["no-encrypted-storage", "no-buttons"]
develop = ["no-encrypted-storage", "log-defmt"]
develop = ["no-encrypted-storage", "log-defmt", "no-buttons", "no-reset-time-window"]
develop-provisioner = ["develop", "provisioner-app"]
develop-secrets = ["develop", "oath"]
@@ -102,11 +101,22 @@ no-reset-time-window = ["fido-authenticator/disable-reset-time-window"]
# Format filesystem anyway
format-filesystem = []
mldsa44 = [
"fido-authenticator/mldsa44",
"trussed/mldsa44",
"trussed-core/mldsa44",
"ctap-types/mldsa44",
# Only activates if piv-authenticator is also enabled.
"piv-authenticator?/mldsa44",
]
# TODO: get rid of these (depends on moving "initialize_basic" &friends into `board`
board-lpcxpresso55 = ["board/lpcxpresso55"]
board-okdoe1 = ["board/okdoe1", "usbfs-peripheral"]
board-solo2 = ["board/solo2"]
test-up-control = ["board/test-up-control"]
log-defmt = []
highspeed = []
+18 -8
View File
@@ -5,14 +5,18 @@ authors.workspace = true
edition.workspace = true
[dependencies]
admin-app .workspace = true
defmt .workspace = true
fm11nc08 .workspace = true
lpc55-hal .workspace = true
rtic = { workspace = true, features = ["thumbv8main-backend"] }
micromath .workspace = true
nb .workspace = true
trussed .workspace = true
admin-app .workspace = true
defmt .workspace = true
embedded-hal .workspace = true
fm11nc08 .workspace = true
generic-array .workspace = true
littlefs2 .workspace = true
lpc55-hal .workspace = true
rtic = { workspace = true, features = ["thumbv8main-backend"] }
micromath .workspace = true
nb .workspace = true
spi-memory .workspace = true
trussed .workspace = true
[features]
lpcxpresso55 = []
@@ -22,3 +26,9 @@ solo2 = []
no-buttons = []
no-clock-controller = []
enable-clock-controller-signal-pin = []
# Replaces button-driven user presence with a probe-rs-writable static
# (`UP_CONTROL`). The integration tests in `runners/pc/tests` use this to
# drive `approve`/`deny`/etc. via `probe-rs write` against the device's
# SRAM. Off by default; firmware shipped to users should NEVER have this on.
test-up-control = []
+447
View File
@@ -0,0 +1,447 @@
//! GD25Q16 external SPI NOR (2 MB) driver.
//!
//! Implements `littlefs2::driver::Storage` so the chip can host a LittleFS
//! mounted at `Location::External`. The chip is physically present only on
//! Solo 2 hardware; on the EVK these pins are unconnected, so JEDEC probe
//! returns `None`. Ported from the Nitrokey 3 Mini reference implementation.
use core::cell::RefCell;
use core::sync::atomic::{AtomicU32, Ordering};
use embedded_hal::{blocking::spi::Transfer, digital::v2::OutputPin};
use littlefs2::{driver::Storage, io::Error};
use spi_memory::{BlockDevice, Read};
// Instrumentation counters bumped on every `Storage` call. Host reads them
// via AdminStatus before and after an op to deduce per-op SPI work.
pub static SPI_READ_COUNT: AtomicU32 = AtomicU32::new(0);
pub static SPI_READ_BYTES: AtomicU32 = AtomicU32::new(0);
pub static SPI_WRITE_COUNT: AtomicU32 = AtomicU32::new(0);
pub static SPI_WRITE_BYTES: AtomicU32 = AtomicU32::new(0);
pub static SPI_ERASE_COUNT: AtomicU32 = AtomicU32::new(0);
use crate::hal::{
self,
drivers::{pins, Pin, Timer},
peripherals::{ctimer, flexcomm::Spi0},
time::{DurationExtensions, RateExtensions},
traits::wg::{
spi::{FullDuplex, Mode, Phase, Polarity},
timer::CountDown,
},
typestates::{
init_state::Enabled,
pin::{self, flexcomm::NoCs},
},
Iocon,
};
// SPI bus pin / type definitions. The bus (FlexComm0 / Spi0) is shared between
// this external GD25Q16 flash and the FM11NC08 NFC reader; CS is gated per
// peripheral. These only define the pins/types; bus init is in the runner.
// Shared SPI bus pins.
pub type SckPin = pins::Pio0_28;
pub type MosiPin = pins::Pio0_24;
pub type MisoPin = pins::Pio0_25;
// External flash dedicated pins.
pub type FlashCsPin = pins::Pio0_13;
pub type FlashPowerPin = pins::Pio0_21;
pub type Sck = Pin<SckPin, pin::state::Special<pin::function::FC0_SCK>>;
pub type Mosi = Pin<MosiPin, pin::state::Special<pin::function::FC0_RXD_SDA_MOSI_DATA>>;
pub type Miso = Pin<MisoPin, pin::state::Special<pin::function::FC0_TXD_SCL_MISO_WS>>;
pub type FlashCs = Pin<FlashCsPin, pin::state::Gpio<pin::gpio::direction::Output>>;
/// FIFO-burst `Transfer<u8>` adapter over a `FullDuplex<u8>` SPI master.
///
/// The default `embedded_hal::blocking::spi::transfer::Default<u8>` impl on
/// lpc55-hal's `SpiMaster` does a strict byte-by-byte ping-pong: send one byte,
/// poll RX-not-empty, read one byte, repeat. The LPC55's SPI peripheral has
/// an 8-deep TX/RX FIFO that the default impl never uses. At 8 MHz SPI the
/// wire-time per byte is ~1 us but the CPU polling between bytes costs ~3 us,
/// so the bus sits idle ~75% of the time.
///
/// This wrapper keeps up to `FIFO_AHEAD` bytes outstanding in TX, draining RX
/// opportunistically. `FIFO_AHEAD = 4` stays well inside the 8-entry FIFO so
/// neither side can overflow.
pub struct BurstSpi<S>(pub S);
const FIFO_AHEAD: usize = 4;
impl<S, E> Transfer<u8> for BurstSpi<S>
where
S: FullDuplex<u8, Error = E>,
{
type Error = E;
fn transfer<'a>(&mut self, buf: &'a mut [u8]) -> Result<&'a [u8], Self::Error> {
let len = buf.len();
let mut tx_pos: usize = 0;
let mut rx_pos: usize = 0;
// No-progress guard: if neither tx nor rx advances over 1_000_000
// iterations, bail out with a fake-success rather than spin forever
// on a non-responsive bus. Caller treats Storage::read failure as IO.
let mut idle: u32 = 0;
while rx_pos < len {
let tx_before = tx_pos;
let rx_before = rx_pos;
while tx_pos < len && tx_pos - rx_pos < FIFO_AHEAD {
match self.0.send(buf[tx_pos]) {
Ok(()) => tx_pos += 1,
Err(nb::Error::WouldBlock) => break,
Err(nb::Error::Other(e)) => return Err(e),
}
}
while rx_pos < tx_pos {
match self.0.read() {
Ok(b) => {
buf[rx_pos] = b;
rx_pos += 1;
}
Err(nb::Error::WouldBlock) => break,
Err(nb::Error::Other(e)) => return Err(e),
}
}
if tx_pos == tx_before && rx_pos == rx_before {
idle = idle.saturating_add(1);
if idle > 1_000_000 {
return Ok(buf);
}
} else {
idle = 0;
}
}
Ok(buf)
}
}
/// Concrete `ExtFlashStorage` for the Solo 2 wiring.
pub type Solo2ExtFlash = ExtFlashStorage<
BurstSpi<
hal::drivers::SpiMaster<
SckPin,
MosiPin,
MisoPin,
hal::typestates::pin::flexcomm::NoPio,
Spi0,
(Sck, Mosi, Miso, NoCs),
>,
>,
FlashCs,
>;
struct FlashProperties {
size: usize,
}
const FLASH_PROPERTIES: FlashProperties = FlashProperties { size: 0x20_0000 };
/// JEDEC IDs accepted as "the 2 MB external flash". All entries are
/// 16 Mbit / 4 KB sector / 256 B page / standard SPI mode-0 chips that
/// behave identically through `spi_memory::series25`.
///
/// `[0xC8, 0x40, 0x15]` GigaDevice GD25Q16CEIGR
/// `[0xEF, 0x40, 0x15]` Winbond W25Q16JV
/// `[0xEF, 0x70, 0x15]` Winbond W25Q16JV-DTR (observed on UUID DD6806...)
const ACCEPTED_JEDEC: &[[u8; 3]] = &[[0xC8, 0x40, 0x15], [0xEF, 0x40, 0x15], [0xEF, 0x70, 0x15]];
fn jedec_accepted(jedec: &[u8; 3]) -> bool {
ACCEPTED_JEDEC.iter().any(|j| j == jedec)
}
pub const SPARE_LEN: usize = 0;
/// Physical erase-sector size of the W25Q16JV / GD25Q16. Cannot be smaller —
/// the chip's `SectorErase` (0x20) command always clears 4 KiB.
const SECTOR_SIZE: usize = 4096;
pub struct ExtFlashStorage<SPI, CS>
where
SPI: Transfer<u8>,
CS: OutputPin,
{
flash: RefCell<spi_memory::series25::Flash<SPI, CS>>,
jedec: [u8; 3],
scratch: RefCell<[u8; SECTOR_SIZE]>,
}
impl<SPI, CS> Storage for ExtFlashStorage<SPI, CS>
where
SPI: Transfer<u8>,
CS: OutputPin,
{
const BLOCK_SIZE: usize = 1024;
const READ_SIZE: usize = 4;
const WRITE_SIZE: usize = 256;
const BLOCK_COUNT: usize =
(FLASH_PROPERTIES.size / Self::BLOCK_SIZE) - (SPARE_LEN / Self::BLOCK_SIZE);
type CACHE_SIZE = generic_array::typenum::U1024;
type LOOKAHEAD_SIZE = generic_array::typenum::U256;
fn read(&mut self, off: usize, buf: &mut [u8]) -> Result<usize, Error> {
if buf.len() > FLASH_PROPERTIES.size || off > FLASH_PROPERTIES.size - buf.len() {
return Err(Error::IO);
}
SPI_READ_COUNT.fetch_add(1, Ordering::Relaxed);
SPI_READ_BYTES.fetch_add(buf.len() as u32, Ordering::Relaxed);
let mut flash = self.flash.borrow_mut();
map_result(flash.read(off as u32, buf), buf.len())
}
fn write(&mut self, off: usize, data: &[u8]) -> Result<usize, Error> {
SPI_WRITE_COUNT.fetch_add(1, Ordering::Relaxed);
SPI_WRITE_BYTES.fetch_add(data.len() as u32, Ordering::Relaxed);
const CHUNK_SIZE: usize = 256;
let mut buf = [0; CHUNK_SIZE];
let mut off = off as u32;
let mut flash = self.flash.borrow_mut();
for chunk in data.chunks(CHUNK_SIZE) {
let buf = &mut buf[..chunk.len()];
buf.copy_from_slice(chunk);
flash.write_bytes(off, buf).map_err(|_| Error::IO)?;
off += CHUNK_SIZE as u32;
}
Ok(data.len())
}
/// LittleFS-driven erase. `off` and `len` are multiples of `BLOCK_SIZE`
/// (1 KiB). The underlying chip can only erase whole 4 KiB sectors, so:
///
/// - if the request covers an entire sector, just `SectorErase`;
/// - otherwise read the sector, set the requested range to 0xFF in RAM,
/// `SectorErase`, rewrite the saved contents back.
fn erase(&mut self, off: usize, len: usize) -> Result<usize, Error> {
if len > FLASH_PROPERTIES.size || off > FLASH_PROPERTIES.size - len {
return Err(Error::IO);
}
debug_assert!(off.is_multiple_of(Self::BLOCK_SIZE));
debug_assert!(len.is_multiple_of(Self::BLOCK_SIZE));
let end = off + len;
let mut cur = off;
let mut flash = self.flash.borrow_mut();
let mut scratch = self.scratch.borrow_mut();
while cur < end {
let sector_off = (cur / SECTOR_SIZE) * SECTOR_SIZE;
let sector_end = sector_off + SECTOR_SIZE;
let range_start = cur;
let range_end = core::cmp::min(end, sector_end);
if range_start == sector_off && range_end == sector_end {
flash
.erase_sectors(
{
SPI_ERASE_COUNT.fetch_add(1, Ordering::Relaxed);
sector_off as u32
},
1,
)
.map_err(|_| Error::IO)?;
} else {
flash
.read(sector_off as u32, scratch.as_mut())
.map_err(|_| Error::IO)?;
let buf_start = range_start - sector_off;
let buf_end = range_end - sector_off;
scratch[buf_start..buf_end].fill(0xFF);
flash
.erase_sectors(
{
SPI_ERASE_COUNT.fetch_add(1, Ordering::Relaxed);
sector_off as u32
},
1,
)
.map_err(|_| Error::IO)?;
flash
.write_bytes(sector_off as u32, scratch.as_mut())
.map_err(|_| Error::IO)?;
}
cur = range_end;
}
Ok(len)
}
}
fn map_result<SPI, CS>(
r: Result<(), spi_memory::Error<SPI, CS>>,
len: usize,
) -> Result<usize, Error>
where
SPI: Transfer<u8>,
CS: OutputPin,
{
match r {
Ok(()) => Ok(len),
Err(_) => Err(Error::IO),
}
}
/// Raw bytes captured from the chip-identity probe. `jedec` is what the bus
/// returned to `0x9F` (always 3 bytes, regardless of whether validation
/// passed). `rdsr_lo` / `rdsr_hi` are RDSR1 (`0x05`) and RDSR2 (`0x35`).
#[derive(Copy, Clone)]
pub struct SelftestResult {
pub jedec: [u8; 3],
pub rdsr_lo: u8,
pub rdsr_hi: u8,
}
impl SelftestResult {
pub const ZERO: Self = Self {
jedec: [0; 3],
rdsr_lo: 0,
rdsr_hi: 0,
};
}
impl<SPI, CS> ExtFlashStorage<SPI, CS>
where
SPI: Transfer<u8>,
CS: OutputPin,
{
/// Probe the chip via JEDEC ID and three diagnostic register reads.
/// Returns `(None, selftest)` if the chip is absent / mismatched —
/// the selftest is always populated so the caller can surface raw
/// bus state for debugging.
pub fn try_new(mut spi: SPI, mut cs: CS) -> (Option<Self>, SelftestResult) {
let selftest = Self::selftest(&mut spi, &mut cs);
if !jedec_accepted(&selftest.jedec) {
defmt::warn!("Unknown Ext. Flash JEDEC: got {=[u8]:#04x}", selftest.jedec,);
return (None, selftest);
}
defmt::info!("Ext. Flash JEDEC accepted: {=[u8]:#04x}", selftest.jedec,);
let flash = match spi_memory::series25::Flash::init(spi, cs) {
Ok(f) => f,
Err(_) => {
defmt::warn!("Ext. Flash JEDEC matched but Flash::init failed");
return (None, selftest);
}
};
(
Some(Self {
flash: RefCell::new(flash),
jedec: selftest.jedec,
scratch: RefCell::new([0u8; SECTOR_SIZE]),
}),
selftest,
)
}
pub fn jedec(&self) -> [u8; 3] {
self.jedec
}
/// Read JEDEC (`0x9F`) + RDSR1 (`0x05`) + RDSR2 (`0x35`) via raw SPI
/// transfers, bypassing `spi_memory`. Always succeeds — on SPI error
/// or absent chip, the bytes captured are whatever the bus held.
fn selftest(spi: &mut SPI, cs: &mut CS) -> SelftestResult {
let mut jedec_buf = [0x9F, 0, 0, 0];
Self::raw_cmd(spi, cs, &mut jedec_buf, "selftest JEDEC");
let jedec = [jedec_buf[1], jedec_buf[2], jedec_buf[3]];
let mut rdsr_lo_buf = [0x05, 0];
Self::raw_cmd(spi, cs, &mut rdsr_lo_buf, "selftest RDSR-low");
let rdsr_lo = rdsr_lo_buf[1];
let mut rdsr_hi_buf = [0x35, 0];
Self::raw_cmd(spi, cs, &mut rdsr_hi_buf, "selftest RDSR-high");
let rdsr_hi = rdsr_hi_buf[1];
defmt::info!(
"selftest: jedec={=[u8]:#04x} rdsr_lo={=u8:#04x} rdsr_hi={=u8:#04x}",
jedec,
rdsr_lo,
rdsr_hi
);
SelftestResult {
jedec,
rdsr_lo,
rdsr_hi,
}
}
fn raw_cmd(spi: &mut SPI, cs: &mut CS, buf: &mut [u8], label: &'static str) {
let _ = cs.set_low();
let r = spi.transfer(buf);
let _ = cs.set_high();
match r {
Ok(resp) => defmt::info!("{=str}: {=[u8]:#04x}", label, &resp[1..]),
Err(_) => defmt::warn!("{=str}: SPI transfer error", label),
}
}
pub fn size(&self) -> usize {
FLASH_PROPERTIES.size
}
pub fn erase_chip(&mut self) -> Result<usize, Error> {
map_result(self.flash.borrow_mut().erase_all(), FLASH_PROPERTIES.size)
}
}
/// Bring up the external flash: drive `FLASH_POWER` high, wait for the
/// chip to settle, configure Spi0 in mode-0 at 8 MHz, JEDEC-probe.
/// Returns `(None, selftest)` if the chip is absent or unresponsive —
/// the caller is expected to fall back to RAM-backed external storage.
///
/// Never panics. Every pin `take()` and bus operation is fallible and
/// returns the zero selftest on failure rather than aborting.
pub fn try_setup<CT>(
spi: Spi0<Enabled>,
gpio: &mut hal::Gpio<Enabled>,
iocon: &mut Iocon<Enabled>,
delay: &mut Timer<CT>,
) -> (Option<Solo2ExtFlash>, SelftestResult)
where
CT: ctimer::Ctimer<Enabled>,
{
use hal::traits::wg::digital::v2::OutputPin as _;
let mut power = match FlashPowerPin::take() {
Some(p) => p.into_gpio_pin(iocon, gpio).into_output_high(),
None => return (None, SelftestResult::ZERO),
};
let _ = power.set_high();
// 200 ms ramp-up (datasheet is ~10 ms; conservative).
delay.start(200_000u32.microseconds());
let _ = nb::block!(delay.wait());
let sck = match SckPin::take() {
Some(p) => p.into_spi0_sck_pin(iocon),
None => return (None, SelftestResult::ZERO),
};
let mosi = match MosiPin::take() {
Some(p) => p.into_spi0_mosi_pin(iocon),
None => return (None, SelftestResult::ZERO),
};
let miso = match MisoPin::take() {
Some(p) => p.into_spi0_miso_pin(iocon),
None => return (None, SelftestResult::ZERO),
};
let cs = match FlashCsPin::take() {
Some(p) => p.into_gpio_pin(iocon, gpio).into_output_high(),
None => return (None, SelftestResult::ZERO),
};
let mode = Mode {
polarity: Polarity::IdleLow,
phase: Phase::CaptureOnFirstTransition,
};
let spi_master = hal::drivers::SpiMaster::new(
spi,
(sck, mosi, miso, pin::flexcomm::NoCs),
8_000_000_u32.Hz(),
mode,
);
ExtFlashStorage::try_new(BurstSpi(spi_master), cs)
}
+1
View File
@@ -24,6 +24,7 @@ pub use shared::{Reboot, CLOCK_FREQ};
pub use specifics::{button::ThreeButtons, led::RgbLed};
pub mod clock_controller;
pub mod flash;
pub mod nfc;
pub mod trussed;
+59 -5
View File
@@ -14,6 +14,25 @@ use trussed::platform::{consent, ui};
// get user presence, this should be fine.
// Used for Ctaphid.keepalive message status.
static mut WAITING: bool = false;
/// Probe-rs-writable user-presence override for automated tests.
///
/// Value semantics (see the match in `check_user_presence` below):
/// 1 = approve once (Normal), consumed after one read
/// 2 = approve once (Strong), consumed after one read
/// 129 = approve sticky (Normal) until reset to 0
/// 130 = approve sticky (Strong) until reset to 0
/// 0 / any other value (incl. uninitialized) = no override; falls
/// through to real button polling. A host "deny" is expressed
/// this way (no tap within the window = timeout).
///
/// Placed in `.uninit` with `no_mangle` so its address is stable and
/// discoverable via the ELF symbol table.
#[cfg(feature = "test-up-control")]
#[unsafe(link_section = ".uninit")]
#[unsafe(no_mangle)]
pub static mut UP_CONTROL: u8 = 0;
pub struct UserPresenceStatus {}
impl UserPresenceStatus {
pub(crate) fn set_waiting(waiting: bool) {
@@ -108,14 +127,44 @@ where
RGB: RgbLed,
{
fn check_user_presence(&mut self) -> consent::Level {
// Probe-rs UP override: write `UP_CONTROL` from the host (via
// probe-rs) to drive automated tests. See the static's docs above
// for the value mapping. One-shot values (1, 2) are consumed.
// The JTAG override is additive: it can only *grant* user presence.
// Any value that is not a recognized approve marker (including 0 and
// uninitialized garbage) falls through to real button polling, so the
// physical buttons always work.
#[cfg(feature = "test-up-control")]
{
let val = unsafe { core::ptr::read_volatile(&raw const UP_CONTROL) };
match val {
1 => {
unsafe { core::ptr::write_volatile(&raw mut UP_CONTROL, 0) };
return consent::Level::Normal;
}
2 => {
unsafe { core::ptr::write_volatile(&raw mut UP_CONTROL, 0) };
return consent::Level::Strong;
}
129 => return consent::Level::Normal,
// Strong sticky — required to drive CTAP 2.3 long-touch
// Reset from a host that can't pulse the UP byte per
// call (the only consent level that satisfies both
// `user_present` and `user_present_strong`).
130 => return consent::Level::Strong,
// 0 / unknown / uninitialized: fall through to real buttons.
_ => {}
}
}
match &mut self.buttons {
Some(buttons) => {
// important to read state before checking for edge,
// since reading an edge could clear the state.
let state = buttons.state();
UserPresenceStatus::set_waiting(true);
// WAITING is driven by set_status() so the periodic keepalive
// task observes UP_NEEDED for the full UP-wait window.
let press_result = buttons.wait_for_any_new_press();
UserPresenceStatus::set_waiting(false);
if press_result.is_ok() {
if state.a && state.b {
consent::Level::Strong
@@ -127,15 +176,20 @@ where
}
}
None => {
// With configured with no buttons, that means Solo is operating
// in passive NFC mode, which means user tapped to indicate presence.
consent::Level::Normal
// no-buttons builds (auto-approve UP for tests + NFC mode).
// Return Strong so it satisfies both Normal-gated ops and the
// Strong-gated Reset; Normal would block Reset entirely.
consent::Level::Strong
}
}
}
fn set_status(&mut self, status: ui::Status) {
self.status = status;
// Drive the static WAITING flag from the trussed status so the
// periodic CTAPHID keepalive task emits STATUS_UPNEEDED for the
// entire UP-wait window (CTAP §11.2.9.1.2).
UserPresenceStatus::set_waiting(matches!(status, ui::Status::WaitingForUserPresence));
debug!("status set to {:?}", defmt::Debug2Format(&status));
// self.refresh runs periodically and would overwrite this
+6 -1
View File
@@ -59,7 +59,12 @@ MEMORY
FILESYSTEM : ORIGIN = 0x{:08X}, LENGTH = {}K
/* for use with standard link.x */
RAM : ORIGIN = 0x20000000, LENGTH = 256K
/* SRAM0-3 (4×64K = 256K) contiguous with SRAM4 (16K @ 0x20040000),
* so 272K is one continuous region for stack/bss/heap. ML-DSA-44
* sign path on this firmware peaks near the limit; the extra 16K
* is the difference between wedging and not wedging on the
* `ctap23_pubkey_cred_params_picks_first_supported_alg` test. */
RAM : ORIGIN = 0x20000000, LENGTH = 272K
/* would be used with proper link.x */
/* needs changes to r0 (initialization code) */
+124 -28
View File
@@ -449,9 +449,27 @@ impl Initializer {
clock_stage: &mut stages::Clock,
basic_stage: &mut stages::Basic,
flexcomm0: hal::peripherals::flexcomm::Flexcomm0<Unknown>,
mux: hal::peripherals::inputmux::InputMux<Unknown>,
pint: hal::peripherals::pint::Pint<Unknown>,
#[allow(unused_variables)] mux: hal::peripherals::inputmux::InputMux<Unknown>,
#[allow(unused_variables)] pint: hal::peripherals::pint::Pint<Unknown>,
) -> stages::Nfc {
let (contactless_requester, contactless_responder) = NFC_APDU_CHANNEL
.split()
.expect("could not setup iso14443 ApduInterchange");
{
if !self.is_nfc_passive {
let _ = mux;
let _ = pint;
let token = clock_stage.clocks.support_flexcomm_token().unwrap();
let spi = flexcomm0.enabled_as_spi(&mut self.syscon, &token);
return stages::Nfc {
iso14443: None,
contactless_responder: Some(contactless_responder),
flash_spi: Some(spi),
};
}
}
let nfc_chip = if self.config.nfc_enabled {
self.try_enable_fm11nc08(
&clock_stage.clocks,
@@ -469,10 +487,6 @@ impl Initializer {
let mut iso14443: Option<types::Iso14443> = None;
let (contactless_requester, contactless_responder) = NFC_APDU_CHANNEL
.split()
.expect("could not setup iso14443 ApduInterchange");
if let Some(chip) = nfc_chip {
iso14443 = Some(nfc_device::Iso14443::new(chip, contactless_requester))
} else if self.is_nfc_passive {
@@ -494,6 +508,7 @@ impl Initializer {
stages::Nfc {
iso14443,
contactless_responder: Some(contactless_responder),
flash_spi: None,
}
}
@@ -708,13 +723,6 @@ impl Initializer {
let internal_fs_alloc =
INTERNAL_FS_ALLOC.init(Filesystem::allocate()) as *mut Allocation<types::FlashStorage>;
static EXTERNAL_STORAGE: StaticCell<ExternalStorage> = StaticCell::new();
let external_storage =
EXTERNAL_STORAGE.init(ExternalStorage::new()) as *mut ExternalStorage;
static EXTERNAL_FS_ALLOC: StaticCell<Allocation<ExternalStorage>> = StaticCell::new();
let external_fs_alloc =
EXTERNAL_FS_ALLOC.init(Filesystem::allocate()) as *mut Allocation<ExternalStorage>;
static VOLATILE_STORAGE: StaticCell<VolatileStorage> = StaticCell::new();
let volatile_storage =
VOLATILE_STORAGE.init(VolatileStorage::new()) as *mut VolatileStorage;
@@ -732,8 +740,9 @@ impl Initializer {
&mut *internal_storage
})
.is_err();
let needs_format = needs_format || cfg!(feature = "format-filesystem");
if needs_format || cfg!(feature = "format-filesystem") {
if needs_format {
if let Some(rgb) = basic_stage.rgb.as_mut() {
rgb.blue(200);
rgb.red(200);
@@ -743,7 +752,6 @@ impl Initializer {
info!("Not yet formatted! Formatting..");
Filesystem::format(unsafe { &mut *internal_storage }).unwrap();
Filesystem::format(unsafe { &mut *external_storage }).unwrap();
Filesystem::format(unsafe { &mut *volatile_storage }).unwrap();
if let Some(rgb) = basic_stage.rgb.as_mut() {
@@ -762,21 +770,86 @@ impl Initializer {
.unwrap(),
);
static EXTERNAL_FS: StaticCell<Filesystem<'static, ExternalStorage>> = StaticCell::new();
let external_fs: &'static mut Filesystem<'static, ExternalStorage> = EXTERNAL_FS.init({
match Filesystem::mount(unsafe { &mut *external_fs_alloc }, unsafe {
&mut *external_storage
}) {
Ok(fs) => fs,
Err(_) => {
Filesystem::format(unsafe { &mut *external_storage }).unwrap();
Filesystem::mount(unsafe { &mut *external_fs_alloc }, unsafe {
&mut *external_storage
})
.unwrap()
// Probe the GD25Q16 at runtime. If present,
// mount it as the external FS. If absent (EVK without the chip,
// dead chip, missing `flash_spi`), fall back to a RAM-backed
// stand-in so the device still enumerates and is reachable —
// never panics, never bricks the device.
let external_fs: &'static dyn trussed::store::DynFilesystem = {
use types::ExternalFallbackStorage;
static EXT_CHIP_STORAGE: StaticCell<ExternalStorage> = StaticCell::new();
static EXT_CHIP_ALLOC: StaticCell<Allocation<ExternalStorage>> = StaticCell::new();
static EXT_CHIP_FS: StaticCell<Filesystem<'static, ExternalStorage>> =
StaticCell::new();
static EXT_RAM_STORAGE: StaticCell<ExternalFallbackStorage> = StaticCell::new();
static EXT_RAM_ALLOC: StaticCell<Allocation<ExternalFallbackStorage>> =
StaticCell::new();
static EXT_RAM_FS: StaticCell<Filesystem<'static, ExternalFallbackStorage>> =
StaticCell::new();
let (chip, _selftest) = match nfc_stage.flash_spi.take() {
Some(spi) => board::flash::try_setup(
spi,
&mut clock_stage.gpio,
&mut clock_stage.iocon,
&mut basic_stage.delay_timer,
),
None => (None, board::flash::SelftestResult::ZERO),
};
match chip {
Some(chip) => {
info!("external flash: GD25Q16 detected, using chip");
let storage = EXT_CHIP_STORAGE.init(chip) as *mut ExternalStorage;
let alloc = EXT_CHIP_ALLOC.init(Filesystem::allocate())
as *mut Allocation<ExternalStorage>;
if needs_format {
info!("wiping external FS (internal reformat or format-filesystem)");
Filesystem::format(unsafe { &mut *storage }).unwrap();
}
let f =
match Filesystem::mount(unsafe { &mut *alloc }, unsafe { &mut *storage }) {
Ok(fs) => fs,
Err(_) => {
Filesystem::format(unsafe { &mut *storage }).unwrap();
Filesystem::mount(unsafe { &mut *alloc }, unsafe { &mut *storage })
.unwrap()
}
};
EXT_CHIP_FS.init(f) as &'static dyn trussed::store::DynFilesystem
}
None => {
defmt::warn!("external flash absent / JEDEC mismatch — RAM fallback");
// Brief red flash so a developer watching a sealed
// Solo 2 (no JTAG / no serial) can tell the chip was
// not detected. Trussed's UI loop overwrites this
// with breathing-green idle shortly after.
if let Some(rgb) = basic_stage.rgb.as_mut() {
rgb.red(200);
rgb.green(0);
rgb.blue(0);
basic_stage.delay_timer.start(250_000.microseconds());
let _ = nb::block!(basic_stage.delay_timer.wait());
rgb.turn_off();
}
let storage = EXT_RAM_STORAGE.init(ExternalFallbackStorage::new())
as *mut ExternalFallbackStorage;
let alloc = EXT_RAM_ALLOC.init(Filesystem::allocate())
as *mut Allocation<ExternalFallbackStorage>;
let f =
match Filesystem::mount(unsafe { &mut *alloc }, unsafe { &mut *storage }) {
Ok(fs) => fs,
Err(_) => {
Filesystem::format(unsafe { &mut *storage }).unwrap();
Filesystem::mount(unsafe { &mut *alloc }, unsafe { &mut *storage })
.unwrap()
}
};
EXT_RAM_FS.init(f) as &'static dyn trussed::store::DynFilesystem
}
}
});
};
static VOLATILE_FS: StaticCell<Filesystem<'static, VolatileStorage>> = StaticCell::new();
let volatile_fs: &'static mut Filesystem<'static, VolatileStorage> = VOLATILE_FS.init({
@@ -814,6 +887,29 @@ impl Initializer {
let store = types::RunnerStore::new(internal_fs, external_fs, volatile_fs);
// Test-only FIDO2 attestation provisioning. Mirrors the DK runner's
// boot-time provisioning: bakes the public Nitrokey FIDO test PKI
// into the binary and writes it to LFS on first boot. Without
// this, CTAP1 `Register` and CTAP2 `MakeCredential` return
// `KeyReferenceNotFound (0x6A88)` and the `tests/fido2::u2f::*`
// suite fails. Gated by `test-up-control` so production builds
// never include the test key.
#[cfg(feature = "test-up-control")]
{
use trussed::store::Store as _;
const ATTESTATION_CERT: &[u8] = include_bytes!("../../pc/data/fido-cert.der");
const ATTESTATION_KEY: &[u8] = include_bytes!("../../pc/data/fido-key.trussed");
let ifs = store.ifs();
if !ifs.exists(littlefs2::path!("fido/x5c/00"))
|| !ifs.exists(littlefs2::path!("fido/sec/00"))
{
let _ = ifs.create_dir_all(littlefs2::path!("fido/x5c"));
let _ = ifs.create_dir_all(littlefs2::path!("fido/sec"));
let _ = ifs.write(littlefs2::path!("fido/x5c/00"), ATTESTATION_CERT);
let _ = ifs.write(littlefs2::path!("fido/sec/00"), ATTESTATION_KEY);
}
}
stages::Filesystem {
store,
internal_storage_fs: internal_storage,
+9 -1
View File
@@ -35,11 +35,19 @@ pub struct Basic {
pub rgb: Option<board::RgbLed>,
}
/// Initialized NFC Iso14443 transport
/// Initialized NFC Iso14443 transport.
///
/// The FM11NC08 is not brought up when the device is USB-powered (SPI0 is
/// consumed by the GD25Q16 flash instead). In that case `iso14443` is `None` and
/// `flash_spi` carries an enabled `Spi0` into the filesystem stage so
/// the actual chip bring-up happens *after* USB has enumerated, keeping
/// the device reachable even if JEDEC probe later fails.
pub struct Nfc {
pub iso14443: Option<nfc_device::Iso14443<'static, board::nfc::NfcChip>>,
pub contactless_responder: Option<apdu_dispatch::interchanges::Responder<'static>>,
pub flash_spi: Option<hal::peripherals::flexcomm::Spi0<hal::Enabled>>,
}
/// Initialized USB device + USB classes, Dynamic Clock controller.
+12
View File
@@ -133,6 +133,18 @@ pub fn init_board(
#[cfg(feature = "provisioner-app")]
let internal_fs = everything.filesystem.internal_storage_fs;
// Run migrations on persistent state before any app touches the filesystem.
// Idempotent: safe to call on every boot, no-op on already-migrated state
// (and on fresh devices where the relevant directories do not exist yet).
#[cfg(feature = "fido-authenticator")]
{
use trussed::store::Store as _;
let _ = fido_authenticator::state::migrate::migrate_no_rp_dir(
everything.filesystem.store.ifs(),
littlefs2::path!("fido/dat"),
);
}
let apps = types::Apps::new(
&mut everything.trussed,
#[cfg(feature = "provisioner-app")]
+14
View File
@@ -116,6 +116,20 @@ mod app {
#[init]
fn init(c: init::Context) -> (SharedResources, LocalResources) {
// Bootloop trap: if the previous reset came from the watchdog, jump
// to MBoot before running init_board so the device stays recoverable.
// The wdtreset bit (PMC.aoreg1) only auto-clears on POR/BOD, so we
// clear it eagerly — a single MBoot recovery flash returns the next
// reset to normal operation.
//
// This build does not arm WWDT, but we keep the trap defensively: a
// prior firmware on the same device might have armed it and fired.
let wdt_caused_reset = c.device.PMC.aoreg1.read().wdtreset().bit();
if wdt_caused_reset {
c.device.PMC.aoreg1.modify(|_, w| w.wdtreset().clear_bit());
runner::hal::boot_to_bootrom();
}
let (
apdu_dispatch,
ctaphid_dispatch,
+91 -91
View File
@@ -5,8 +5,9 @@ use hal::drivers::timer;
use hal::peripherals::ctimer;
use littlefs2::{const_ram_storage, consts};
use trussed::backend::BackendId;
use trussed::client::{ClientTag, CurrentTagCell, MultiplexedClient, SharedRequesterCell};
use trussed::interrupt::InterruptFlag;
use trussed::pipe::{ServiceEndpoint, TrussedChannel};
use trussed::pipe::{MultiplexedEndpoint, TrussedChannel};
use trussed::platform;
use trussed::serde_extensions::{ExtensionDispatch, ExtensionId, ExtensionImpl};
use trussed::store::DynFilesystem;
@@ -210,18 +211,9 @@ const_ram_storage!(
path_max_plus_one_ty = consts::U256,
);
// minimum: 2 blocks
// TODO: make this optional
// piv-authenticator hardcodes Location::External for PUK_USER_KEY_BACKUP in init_pins,
// and opcard defaults to Location::External for its persistent state + ADMIN_USER_KEY_BACKUP.
// Both write to ExternalStorage during init_pins; the write fails on a 2-block filesystem
// (zero data blocks) → syscall!() panic → panic_halt → firmware freeze.
// Anything writing to External needs at least 3 blocks (2 metadata + 1 data).
// We keep 8192 bytes (16 blocks) whenever either app is enabled.
#[cfg(not(any(feature = "piv-authenticator", feature = "opcard")))]
const_ram_storage!(ExternalStorage, 1024);
#[cfg(any(feature = "piv-authenticator", feature = "opcard"))]
const_ram_storage!(ExternalStorage, 8192);
pub type ExternalStorage = board::flash::Solo2ExtFlash;
const_ram_storage!(ExternalFallbackStorage, 4096);
/// Store implementation using three mounted littlefs2 filesystems.
#[derive(Clone, Copy)]
@@ -469,10 +461,13 @@ impl trussed::client::Syscall for Syscall {
}
}
/// Service endpoint type for our Dispatch.
pub type TrussedEndpoint = ServiceEndpoint<'static, BackendIds, RunnerContext>;
/// Client type for apps — parameterized with Dispatch to get extension support.
pub type TrussedClient = trussed::ClientImplementation<'static, Syscall, Dispatch>;
/// Multiplexed service endpoint: one shared responder, per-client contexts
/// keyed by `ClientTag`.
pub type TrussedEndpoint = MultiplexedEndpoint<'static, BackendIds, RunnerContext>;
/// Client type for apps — all apps share the SHARED_TRUSSED_CHANNEL and are
/// distinguished by a per-app `ClientTag` so the service can route requests
/// to the matching context.
pub type TrussedClient = MultiplexedClient<Syscall, Dispatch>;
/// Backends for most apps: StagingBackend (FsInfo, Hkdf, Manage) + Core.
/// BackendId::Core must be present or all standard crypto/filesystem calls return
@@ -506,28 +501,41 @@ static OPCARD_BACKENDS: [BackendId<BackendIds>; 3] = [
BackendId::Core,
];
/// Wrapper around the trussed Service that also holds the service endpoints.
/// Wrapper around the trussed Service that owns the multiplexed endpoint.
/// `process()` and `update_ui()` are called from the RTIC OS_EVENT handler and
/// the periodic UI task respectively.
pub struct Trussed {
service: trussed::Service<Board, Dispatch>,
endpoints: heapless::Vec<TrussedEndpoint, 8>,
endpoint: TrussedEndpoint,
}
impl Trussed {
pub fn new(service: trussed::Service<Board, Dispatch>) -> Self {
let (req, resp) = SHARED_TRUSSED_CHANNEL
.split()
.expect("shared trussed channel already split");
SHARED_REQUESTER.init(req);
Self {
service,
endpoints: heapless::Vec::new(),
endpoint: MultiplexedEndpoint::new(resp),
}
}
pub fn add_endpoint(&mut self, ep: TrussedEndpoint) {
self.endpoints.push(ep).ok();
pub fn register_client(
&mut self,
tag: ClientTag,
context: trussed::types::Context<RunnerContext>,
backends: &'static [BackendId<BackendIds>],
) {
self.endpoint
.register((tag, context, backends))
.map_err(|_| ())
.expect("MultiplexedEndpoint full");
}
pub fn process(&mut self) {
self.service.process(&mut self.endpoints);
self.service
.process_multiplexed(&mut self.endpoint, &CURRENT_TAG);
}
pub fn update_ui(&mut self) {
@@ -573,8 +581,6 @@ pub type AdminApp = admin_app::App<TrussedClient, board::Reboot, AdminStatus>;
pub type PivApp = piv_authenticator::Authenticator<TrussedClient>;
#[cfg(feature = "opcard")]
pub type OpcardApp = opcard::Card<TrussedClient>;
#[cfg(feature = "oath-authenticator")]
pub type OathApp = oath_authenticator::Authenticator<TrussedClient>;
#[cfg(feature = "oath")]
pub type SecretsApp = secrets_app::Authenticator<TrussedClient>;
#[cfg(feature = "fido-authenticator")]
@@ -593,56 +599,62 @@ pub type DynamicClockController = board::clock_controller::DynamicClockControlle
pub type NfcWaitExtender = timer::Timer<ctimer::Ctimer0<hal::typestates::init_state::Enabled>>;
pub type PerformanceTimer = timer::Timer<ctimer::Ctimer4<hal::typestates::init_state::Enabled>>;
// Static trussed channels — one per app. Channels are split during Apps::new().
#[cfg(feature = "admin-app")]
static ADMIN_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
/// Single channel shared across every app. The requester half is stashed in
/// `SHARED_REQUESTER` so all `MultiplexedClient`s can submit requests through
/// it; the responder half is owned by the `MultiplexedEndpoint` inside
/// `Trussed` and pumped by `Service::process_multiplexed`.
static SHARED_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
static SHARED_REQUESTER: SharedRequesterCell = SharedRequesterCell::new();
/// Set by whichever client most recently submitted a request; read by the
/// service to find the matching context.
static CURRENT_TAG: CurrentTagCell = CurrentTagCell::new();
/// Per-app ClientTag (1..=N; 0 reserved as "no client"). Each value must be
/// distinct so `process_multiplexed` can route requests to the right context.
#[allow(dead_code)]
mod client_tag {
use super::ClientTag;
pub const ADMIN: ClientTag = 1;
pub const FIDO: ClientTag = 2;
pub const SECRETS: ClientTag = 4;
pub const PIV: ClientTag = 5;
pub const OPCARD: ClientTag = 6;
pub const PROVISIONER: ClientTag = 7;
}
#[cfg(feature = "admin-app")]
static ADMIN_INTERRUPT: InterruptFlag = InterruptFlag::new();
#[cfg(feature = "fido-authenticator")]
static FIDO_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
#[cfg(feature = "fido-authenticator")]
static FIDO_INTERRUPT: InterruptFlag = InterruptFlag::new();
#[cfg(feature = "oath-authenticator")]
static OATH_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
#[cfg(feature = "oath-authenticator")]
static OATH_INTERRUPT: InterruptFlag = InterruptFlag::new();
#[cfg(feature = "piv-authenticator")]
static PIV_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
#[cfg(feature = "piv-authenticator")]
static PIV_INTERRUPT: InterruptFlag = InterruptFlag::new();
#[cfg(feature = "opcard")]
static OPCARD_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
#[cfg(feature = "opcard")]
static OPCARD_INTERRUPT: InterruptFlag = InterruptFlag::new();
#[cfg(feature = "provisioner-app")]
static PROVISIONER_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
#[cfg(feature = "provisioner-app")]
static PROVISIONER_INTERRUPT: InterruptFlag = InterruptFlag::new();
#[cfg(feature = "oath")]
static SECRETS_TRUSSED_CHANNEL: TrussedChannel = TrussedChannel::new();
#[cfg(feature = "oath")]
static SECRETS_INTERRUPT: InterruptFlag = InterruptFlag::new();
/// Helper: split a static channel, register the service endpoint with `trussed`,
/// and return the client end.
/// Register a multiplexed client with the shared trussed service and return
/// the corresponding `MultiplexedClient`. The runner contributes the
/// per-app `tag`, `client_id` directory, optional `interrupt`, and the
/// backends list used to route extension calls.
fn make_client(
channel: &'static TrussedChannel,
tag: ClientTag,
client_id: &'static littlefs2::path::Path,
trussed: &mut Trussed,
interrupt: Option<&'static InterruptFlag>,
backends: &'static [BackendId<BackendIds>],
) -> TrussedClient {
let (req, resp) = channel.split().expect("channel already split");
let context = CoreContext::with_interrupt(littlefs2::path::PathBuf::from(client_id), interrupt);
let ep = ServiceEndpoint::new(resp, context, backends);
trussed.add_endpoint(ep);
TrussedClient::new(req, Syscall::default(), interrupt)
trussed.register_client(tag, context.into(), backends);
MultiplexedClient::new(
&SHARED_REQUESTER,
&CURRENT_TAG,
tag,
Syscall::default(),
interrupt,
)
}
pub struct ProvisionerNonPortable {
@@ -656,8 +668,6 @@ pub struct Apps {
pub admin: AdminApp,
#[cfg(feature = "fido-authenticator")]
pub fido: FidoApp,
#[cfg(feature = "oath-authenticator")]
pub oath: OathApp,
#[cfg(feature = "oath")]
pub secrets: SecretsApp,
#[cfg(feature = "ndef-app")]
@@ -678,7 +688,7 @@ impl Apps {
#[cfg(feature = "admin-app")]
let admin = {
let client = make_client(
&ADMIN_TRUSSED_CHANNEL,
client_tag::ADMIN,
littlefs2::path!("admin"),
trussed,
Some(&ADMIN_INTERRUPT),
@@ -697,7 +707,7 @@ impl Apps {
#[cfg(feature = "fido-authenticator")]
let fido = {
let client = make_client(
&FIDO_TRUSSED_CHANNEL,
client_tag::FIDO,
littlefs2::path!("fido"),
trussed,
Some(&FIDO_INTERRUPT),
@@ -710,58 +720,52 @@ impl Apps {
max_msg_size: ctaphid_dispatch::DEFAULT_MESSAGE_SIZE,
skip_up_timeout: None,
max_resident_credential_count: Some(50),
large_blobs: None,
nfc_transport: false,
// CTAP 2.1 §6.10: minimum array size is 1024.
large_blobs: Some(fido_authenticator::LargeBlobsConfig {
location: trussed::types::Location::External,
max_size: 1024,
}),
nfc_transport: true,
ccid_transport: false,
firmware_version: Some((build_constants::CARGO_PKG_VERSION as usize).into()),
// V2 credential-id format: AES-256-GCM. Applied on a clean
// state / after factory reset; existing V1 credentials persist.
credential_id_version: Some(
fido_authenticator::credential::CredentialIdVersion::V2,
),
long_touch_for_reset: true,
fido2_up_timeout: None,
},
)
};
#[cfg(feature = "oath-authenticator")]
let oath = {
let client = make_client(
&OATH_TRUSSED_CHANNEL,
littlefs2::path!("oath"),
trussed,
Some(&OATH_INTERRUPT),
&STAGING_BACKENDS,
);
OathApp::new(client)
};
#[cfg(feature = "piv-authenticator")]
let piv = {
let client = make_client(
&PIV_TRUSSED_CHANNEL,
client_tag::PIV,
littlefs2::path!("piv"),
trussed,
Some(&PIV_INTERRUPT),
&PIV_BACKENDS,
);
// Use Internal storage: External is only 1024 bytes (too small for PIV key material).
// PIV needs Auth backend for PIN management (has_pin/set_pin/get_pin_key) and
// StagingBackend for Chunked/Hpke/WrapKeyToFile extensions.
PivApp::new(
client,
piv_authenticator::Options::default().storage(trussed::types::Location::Internal),
piv_authenticator::Options::default().storage(trussed::types::Location::External),
)
};
#[cfg(feature = "opcard")]
let opcard = {
let client = make_client(
&OPCARD_TRUSSED_CHANNEL,
client_tag::OPCARD,
littlefs2::path!("opcard"),
trussed,
Some(&OPCARD_INTERRUPT),
&OPCARD_BACKENDS,
);
// Use Internal storage so card state (PINs, keys) persists across reboots.
// opcard::Options::default() uses Location::External (volatile RAM) which would
// lose all card state on every reboot and cause init_pins to write
// ADMIN_USER_KEY_BACKUP to ExternalStorage — same failure mode as PIV.
{
let mut opts = opcard::Options::default();
opts.storage = trussed::types::Location::Internal;
opts.storage = trussed::types::Location::External;
OpcardApp::new(client, opts)
}
};
@@ -769,7 +773,7 @@ impl Apps {
#[cfg(feature = "oath")]
let secrets = {
let client = make_client(
&SECRETS_TRUSSED_CHANNEL,
client_tag::SECRETS,
littlefs2::path!("secrets"),
trussed,
Some(&SECRETS_INTERRUPT),
@@ -779,7 +783,7 @@ impl Apps {
SecretsApp::new(
client,
secrets_app::Options::new(
trussed::types::Location::Internal,
trussed::types::Location::External,
0, // custom_status_reverse_hotp_success
1, // custom_status_reverse_hotp_error
[uuid[0], uuid[1], uuid[2], uuid[3]],
@@ -794,7 +798,7 @@ impl Apps {
#[cfg(feature = "provisioner-app")]
let provisioner = {
let client = make_client(
&PROVISIONER_TRUSSED_CHANNEL,
client_tag::PROVISIONER,
littlefs2::path!("attn"),
trussed,
Some(&PROVISIONER_INTERRUPT),
@@ -813,8 +817,6 @@ impl Apps {
admin,
#[cfg(feature = "fido-authenticator")]
fido,
#[cfg(feature = "oath-authenticator")]
oath,
#[cfg(feature = "oath")]
secrets,
#[cfg(feature = "ndef-app")]
@@ -840,8 +842,6 @@ impl Apps {
&mut self.piv,
#[cfg(feature = "opcard")]
&mut self.opcard,
#[cfg(feature = "oath-authenticator")]
&mut self.oath,
#[cfg(feature = "oath")]
&mut self.secrets,
#[cfg(feature = "fido-authenticator")]
+22 -13
View File
@@ -12,10 +12,20 @@ path = "src/lib.rs"
name = "solo-pc"
path = "src/bin/main.rs"
# Integration tests reach into `solo_pc::buttons` to drive simulated UP
# (`up::approve()` / `deny()`), so the suite requires the `test-buttons`
# feature. With `--no-default-features` cargo skips this test target
# entirely.
[[test]]
name = "fido2"
path = "tests/fido2.rs"
required-features = ["test-buttons"]
[dependencies]
chacha20.workspace = true
delog.workspace = true
embedded-hal.workspace = true
embedded-time.workspace = true
generic-array.workspace = true
interchange.workspace = true
nb.workspace = true
@@ -24,14 +34,15 @@ admin-app.workspace = true
apdu-dispatch.workspace = true
ctap-types.workspace = true
ctaphid-dispatch.workspace = true
fido-authenticator = { workspace = true, optional = true }
fido-authenticator = { workspace = true, optional = true, features = ["mldsa44"] }
piv-authenticator.workspace = true
trussed.workspace = true
trussed-core.workspace = true
trussed = { workspace = true, features = ["mldsa44"] }
trussed-core = { workspace = true, features = ["mldsa44"] }
# components
usbd-ccid.workspace = true
usbd-ctaphid.workspace = true
usb-device.workspace = true
nfc-device.workspace = true
ndef-app.workspace = true
@@ -39,9 +50,15 @@ ndef-app.workspace = true
littlefs2.workspace = true
[dev-dependencies]
fido-authenticator = { workspace = true }
ctap-types.workspace = true
fido-authenticator = { workspace = true, features = ["mldsa44"] }
# `test-client` adds Serialize impls on CTAP2 Request types and Deserialize
# on Response types — needed by the host-side test harness (we're the
# client). Chip-side builds (above, in [dependencies]) don't enable this.
ctap-types = { workspace = true, features = ["platform-serde"] }
ctaphid-dispatch.workspace = true
# Host-side CTAP Request → CBOR + Response ← CBOR for the Device backend.
# Same crate ctap-types uses internally to serialize responses.
cbor-smol = "0.5"
heapless = { workspace = true }
rand.workspace = true
serde.workspace = true
@@ -85,14 +102,6 @@ test-buttons = []
# platform clock used for that timeout comparison (busy-poll loop stays correct).
test-fast-up-clock = []
# **Not** the default: no `buttons` module; `check_user_presence` always returns `Normal` without
# consulting hardware or test doubles — you **cannot** test denied UP / timeout behaviour, and
# nothing records "UP was requested". Use only for a minimal PC binary that does not run FIDO2 UP tests.
no-test-ui = []
# Alias for `no-test-ui` (same name as the board crate; meaning here is PC-only).
no-buttons = ["no-test-ui"]
# Reconfigure the NFC chip in any case
reconfigure-nfc = []
Binary file not shown.
Binary file not shown.
+70 -1
View File
@@ -129,7 +129,7 @@ impl TestThreeButtons {
fn take_press_token(&self) -> bool {
let val = self.up_response.load(Ordering::SeqCst);
let grant = matches!(val, AUTO_APPROVE | APPROVE_ONCE | APPROVE_STICKY);
if val > 0 && val < 128 {
if val == APPROVE_ONCE {
let _ = self.up_response.compare_exchange(
val,
AUTO_APPROVE,
@@ -193,6 +193,75 @@ pub fn approve_sticky() {
test_three_buttons().lock().unwrap().approve_sticky();
}
// --- Single-shot UP-mode queue (vctaphid test driver) ---------------------
//
// Tests call `device.up_set_mode("tap" | "do_not_tap" | "long_tap")` which
// (on vctaphid) writes a magic packet. The daemon stores the requested
// mode here, *queued* — it doesn't take effect immediately because the
// surrounding test workflow can issue non-UP-needing requests between
// the mode-set and the request that actually needs UP. Instead, the
// `solo_pc::UserInterface::set_status(WaitingForUserPresence)` hook
// consumes the queued mode and arms the corresponding `buttons` state
// for the duration of *that one* UP wait, then resets back to
// approve_sticky so subsequent (non-UP-related) traffic isn't affected.
/// 0 = nothing queued (fall through to the default approve_sticky).
pub const UP_MODE_NONE: u8 = 0;
/// One tap: APPROVE_ONCE for the next UP poll loop.
pub const UP_MODE_TAP: u8 = 1;
/// Don't tap: DENY_STICKY for the next UP poll loop (trussed sees no
/// consent, times out, returns `UserActionTimeout`).
pub const UP_MODE_DO_NOT_TAP: u8 = 2;
/// Long-press: same `buttons` state as `UP_MODE_TAP` on vctaphid since
/// our held state is always (a:true, b:true), so any granted token
/// yields `Level::Strong`. Distinguished from `tap` only for the
/// human-facing prompt and for future runners that *can* tell short
/// from long presses apart.
pub const UP_MODE_LONG_TAP: u8 = 3;
static QUEUED_UP_MODE: AtomicU8 = AtomicU8::new(UP_MODE_NONE);
pub fn queue_up_mode(mode: u8) {
QUEUED_UP_MODE.store(mode, Ordering::SeqCst);
}
/// Atomically take the queued mode (returning it) and reset the queue
/// back to `UP_MODE_NONE`. Called by the platform UI when trussed
/// signals it's entering a UP-poll loop.
pub fn take_queued_up_mode() -> u8 {
QUEUED_UP_MODE.swap(UP_MODE_NONE, Ordering::SeqCst)
}
// --- "Don't grant before this instant" deadline -------------------------
//
// On real hardware UP costs the user a noticeable button press (~500 ms
// human reaction). usbd-ctaphid's keepalive timer relies on that —
// `did_start_processing` schedules the first KEEPALIVE 250 ms after a
// CBOR command starts, on the assumption that the authenticator is
// still inside its UP-poll loop by then. Our buttons-based UI grants
// instantly, so we never cross the 250 ms threshold and
// `test_keep_alive` sees no keepalives.
//
// To match the LPC55 wire behaviour we slow the *default* UP path
// (i.e. no test-queued mode) to artificially take ~300 ms. The
// `solo_pc::UserInterface::check_user_presence` hook reads this
// deadline before consulting the button state; if `Instant::now() <
// deadline`, it returns `None` (re-poll) regardless. Test-queued modes
// (`tap` / `do_not_tap` / `long_tap`) bypass the deadline because
// they're explicit "the human just acted" signals.
use std::time::Instant;
static UP_GRANT_NOT_BEFORE: Mutex<Option<Instant>> = Mutex::new(None);
pub fn set_up_grant_deadline(deadline: Option<Instant>) {
*UP_GRANT_NOT_BEFORE.lock().unwrap() = deadline;
}
pub fn up_grant_deadline() -> Option<Instant> {
*UP_GRANT_NOT_BEFORE.lock().unwrap()
}
pub fn deny() {
test_three_buttons().lock().unwrap().deny();
}
+96 -12
View File
@@ -158,8 +158,24 @@ impl trussed::platform::UserInterface for UserInterface {
fn check_user_presence(&mut self) -> consent::Level {
#[cfg(feature = "test-buttons")]
{
// Single-path UI backed by `buttons::test_three_buttons()`.
// Tests (and anything else that wants to influence consent)
// control it through `buttons::approve` / `approve_sticky` /
// `deny` / `reset`.
use crate::buttons::{self, Edge, Press};
USER_PRESENCE_POLLS.fetch_add(1, Ordering::Relaxed);
// Optional "don't grant before this instant" deadline,
// armed by callers that need processing to last long enough
// for usbd-ctaphid's 250 ms keepalive timer to fire — see
// `buttons::set_up_grant_deadline` for the full rationale.
if let Some(deadline) = buttons::up_grant_deadline() {
if std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
return consent::Level::None;
}
}
let (state, press_result) = {
let mut buttons = buttons::test_three_buttons().lock().unwrap();
let state = buttons.state();
@@ -173,29 +189,62 @@ impl trussed::platform::UserInterface for UserInterface {
consent::Level::Normal
}
} else {
// Do not hold `test_three_buttons` mutex across sleep: the test thread may need
// the lock for `up::approve()` / `reset()` on the next operation.
// Do not hold `test_three_buttons` mutex across sleep:
// another thread may need the lock for `approve()` /
// `reset()` on the next operation.
std::thread::sleep(std::time::Duration::from_millis(50));
consent::Level::None
}
}
#[cfg(all(feature = "no-test-ui", not(feature = "test-buttons")))]
#[cfg(not(feature = "test-buttons"))]
{
// No test-buttons module compiled in — `--no-default-features`
// build. Auto-approve so a non-interactive binary still works;
// tests opt into the simulated-buttons UI by enabling
// `test-buttons` (the default).
consent::Level::Normal
}
#[cfg(not(any(feature = "test-buttons", feature = "no-test-ui")))]
{
compile_error!(
"solo-pc: enable the default `test-buttons` feature (simulated hardware UP for tests) \
or opt into `no-test-ui` for a non-interactive UI only (`--no-default-features --features no-test-ui`)."
)
}
}
fn set_status(&mut self, status: ui::Status) {
println!("Set status: {:?}", status);
// Hook the trussed UP-poll lifecycle. trussed calls
// `set_status(WaitingForUserPresence)` immediately before its poll
// loop and `set_status(<previous>)` after the loop exits. We use
// that boundary to apply the test-queued mode (tap / do_not_tap /
// long_tap) for the duration of exactly ONE UP request. That way
// `device.up_set_mode("tap")` is a single-shot instruction with
// no cleanup call required from tests.
//
// Only the queued-test-mode branches mutate `up_response`. The
// default (no test queue) and non-UP statuses are no-ops: the
// daemon's startup already installs the desired default
// (approve_sticky), and in-process tests drive `up_response`
// directly via `solo_pc::buttons::approve` / `deny` / `reset` —
// clobbering it from set_status would race the test. The 350 ms
// grant-deadline armed by the daemon's rx CBOR handler is also
// left alone here; trussed fires Processing/Idle repeatedly during
// a single request, and clearing it on every transition would wipe
// the deadline before WaitingForUserPresence could read it.
#[cfg(feature = "test-buttons")]
if matches!(status, ui::Status::WaitingForUserPresence) {
use crate::buttons;
let guard = buttons::test_three_buttons().lock().unwrap();
match buttons::take_queued_up_mode() {
buttons::UP_MODE_TAP | buttons::UP_MODE_LONG_TAP => {
guard.approve();
buttons::set_up_grant_deadline(None);
}
buttons::UP_MODE_DO_NOT_TAP => {
guard.deny();
buttons::set_up_grant_deadline(None);
}
_ => {}
}
}
#[cfg(not(feature = "test-buttons"))]
let _ = status;
}
fn refresh(&mut self) {}
@@ -207,7 +256,18 @@ impl trussed::platform::UserInterface for UserInterface {
// fido-authenticator uses a 30 s UP window; Trussed busy-polls `check_user_presence`
// until that **uptime delta** elapses. Scaling time shortens denied-UP wall time without
// changing whether consent is granted (still driven by `buttons` / `up::`).
const SCALE: u32 = 100;
//
// SCALE picked to give comfortable margin from the grant-deadline path:
// - approve_sticky default: deadline = 300 ms wall (see `set_status`)
// - SCALE=30 → scaled 30 s UP timeout = 1.0 s wall, so the grant
// fires ~700 ms before the timeout. SCALE=100 used to put the
// timeout at 300 ms wall — exactly tied with the deadline, and
// clock jitter from `thread::sleep(50ms)` accumulation routinely
// pushed trussed over the line first, producing UserActionTimeout
// instead of the intended approval (broke test_keep_alive).
// - do_not_tap test (`Timeout(2.0)` on the python side) still
// completes well within budget at 1.0 s daemon-side wall.
const SCALE: u32 = 30;
elapsed.saturating_mul(SCALE)
}
#[cfg(not(feature = "test-fast-up-clock"))]
@@ -268,3 +328,27 @@ pub fn mount_filesystems() -> RunnerStore {
vfs: volatile_fs,
}
}
/// Plant a FIDO U2F batch-attestation key+certificate in the freshly-mounted
/// internal filesystem.
///
/// `fido-authenticator` looks up the attestation key at trussed path
/// `fido/sec/00` and the X.509 cert at `fido/x5c/00`. Real hardware writes
/// these via the `provisioner-app` during factory provisioning; the host
/// daemon has no such step and otherwise reports
/// `KeyReferenceNotFound` (0x6A88) when CTAP1 `Register` runs. The cert
/// and key bytes come from the Nitrokey FIDO test PKI bundled with
/// `fido-authenticator`'s own integration tests, copied into
/// `runners/pc/data/`.
pub fn provision_fido_attestation(store: &RunnerStore) {
use trussed::store::Store as _;
const ATTESTATION_CERT: &[u8] = include_bytes!("../data/fido-cert.der");
const ATTESTATION_KEY: &[u8] = include_bytes!("../data/fido-key.trussed");
let ifs = store.ifs();
let _ = ifs.create_dir_all(littlefs2::path!("fido/x5c"));
let _ = ifs.create_dir_all(littlefs2::path!("fido/sec"));
let _ = ifs.write(littlefs2::path!("fido/x5c/00"), ATTESTATION_CERT);
let _ = ifs.write(littlefs2::path!("fido/sec/00"), ATTESTATION_KEY);
}
+105 -14
View File
@@ -118,6 +118,14 @@ macro_rules! with_authenticator {
max_resident_credential_count: None,
large_blobs: None,
nfc_transport: false,
ccid_transport: false,
firmware_version: Some(0.into()),
// Exercise the AES-256-GCM (V2) credential-id format.
credential_id_version: Some(
fido_authenticator::credential::CredentialIdVersion::V2,
),
long_touch_for_reset: true,
fido2_up_timeout: None,
},
);
let $authn: &mut dyn TestAuthenticator = &mut sim;
@@ -308,31 +316,75 @@ fn make_credential(authn: &mut dyn TestAuthenticator) -> Vec<u8> {
// --- Device reset helper ---
/// Reboot the device (device mode only) so CTAP2 Reset is within the 10s window.
///
/// Uses JLinkExe rather than `probe-rs reset` because probe-rs's
/// auto-recovery on the nRF52840 IC rev D+ family triggers an ERASEALL
/// when the chip looks "locked," which wipes UICR.APPROTECT back to
/// 0xFFFFFFFF (= HwEnabled under the new semantics) and re-locks the
/// chip from subsequent probe-rs operations. JLinkExe just does a
/// clean AIRCR.SYSRESETREQ via its own debug sequence.
fn device_reboot() {
if !transport::is_device_mode() {
return;
}
if std::env::var("SKIP_DEVICE_REBOOT").is_ok() {
eprintln!("device_reboot: SKIP_DEVICE_REBOOT set — skipping reset");
return;
}
let chip = std::env::var("PROBE_RS_CHIP").unwrap_or("LPC55S69JBD100".into());
let protocol = std::env::var("PROBE_RS_PROTOCOL").ok();
let speed = std::env::var("PROBE_RS_SPEED").ok();
let mut cmd = std::process::Command::new("probe-rs");
cmd.args(["reset", "--chip", &chip]);
if let Some(p) = protocol.as_deref() {
cmd.args(["--protocol", p]);
}
if let Some(s) = speed.as_deref() {
cmd.args(["--speed", s]);
// `PROBE_RS_PROBE` is VID:PID:SerialNumber. JLinkExe wants only the SN.
let probe_sn = std::env::var("PROBE_RS_PROBE")
.ok()
.and_then(|s| s.rsplit(':').next().map(|x| x.to_string()));
let device = match chip.as_str() {
"LPC55S69JBD100" => "LPC55S69",
other => other,
};
let script_path = "/tmp/device-reset.jlink";
let _ = std::fs::write(script_path, "r\ng\nq\n");
let mut cmd = std::process::Command::new("JLinkExe");
if let Some(sn) = probe_sn.as_deref() {
cmd.args(["-SelectEmuBySN", sn]);
}
cmd.args([
"-device",
device,
"-if",
"SWD",
"-speed",
"4000",
"-autoconnect",
"1",
"-CommanderScript",
script_path,
]);
let _ = cmd.status();
std::thread::sleep(std::time::Duration::from_secs(1));
std::thread::sleep(std::time::Duration::from_secs(2));
}
/// Reset the authenticator to a clean state (no credentials, no PIN).
/// Reboots the device (device mode), reconnects, then sends CTAP2 Reset.
///
/// Sends CTAP2 Reset gated by `consent::Level::Strong` (CTAP 2.3 §7.7
/// long-touch reset). The long-touch path drops the legacy 10-second
/// boot-window restriction, so no chip reboot is required — much
/// faster than the old `probe-rs reset` flow and doesn't churn the
/// host USB bus (important on VMs/hypervisors that don't auto-rebind
/// on re-enumeration).
///
/// Set `RESET_AUTHENTICATOR_REBOOT=1` to fall back to the historical
/// reboot-then-Reset flow (CTAP 2.0/2.1, pre-long-touch firmware).
/// Set `SKIP_RESET_AUTHENTICATOR=1` to skip cleanup entirely (for
/// tests that don't need a clean store).
fn reset_authenticator(authn: &mut dyn TestAuthenticator) {
device_reboot();
authn.reconnect();
up::approve();
if std::env::var("SKIP_RESET_AUTHENTICATOR").is_ok() {
eprintln!("reset_authenticator: SKIP_RESET_AUTHENTICATOR set — skipping");
return;
}
if std::env::var("RESET_AUTHENTICATOR_REBOOT").is_ok() {
device_reboot();
authn.reconnect();
}
up::approve_strong();
let _ = authn.call_ctap2(&Request::Reset);
}
@@ -370,3 +422,42 @@ mod cred_protect;
#[path = "fido2/hmac_secret.rs"]
mod hmac_secret;
#[path = "fido2/ctap_2_2.rs"]
mod ctap_2_2;
#[path = "fido2/ctap_2_3.rs"]
mod ctap_2_3;
#[path = "fido2/u2f.rs"]
mod u2f;
#[path = "fido2/ctap1_interop.rs"]
mod ctap1_interop;
#[path = "fido2/authenticator_config.rs"]
mod authenticator_config;
#[path = "fido2/ext_cred_blob.rs"]
mod ext_cred_blob;
#[path = "fido2/ext_min_pin_length.rs"]
mod ext_min_pin_length;
#[path = "fido2/ext_third_party_payment.rs"]
mod ext_third_party_payment;
#[path = "fido2/ext_pin_complexity.rs"]
mod ext_pin_complexity;
#[path = "fido2/large_blobs.rs"]
mod large_blobs;
#[path = "fido2/ext_large_blob.rs"]
mod ext_large_blob;
#[path = "fido2/ext_large_blob_key.rs"]
mod ext_large_blob_key;
#[path = "fido2/ctaphid_transport.rs"]
mod ctaphid_transport;

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