mirror of
https://github.com/solokeys/solo2.git
synced 2026-06-20 13:16:13 -07:00
ctap2.3: PC + LPC infrastructure for CTAP 2.3 / ML-DSA-44 work
This commit is contained in:
Generated
+657
-156
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user