unsafe audit
August 28, 2026 · View on GitHub
The firmware is no_std Rust. Safety of the parsers and applet logic is the
core defensive property, so every unsafe is enumerated here: its
justification, why a safe alternative does not work, and how the risk is
contained. Adding a new unsafe requires updating this page. (Safe Rust rules
out memory-corruption bugs in this code. It is not a security audit; see the
threat model.)
Runtime sites: 21. Twelve in the firmware proper (main.rs + presence.rs):
the interrupt-handler pair (2), the Send impl, the heap init, and the eight
GPIO-pin steals (the presence button, the LED power-enable rail, the nuisance
USR LED, the display build's wake button, and — display builds only — the panel's
CS/DC/RST/TP_RST control lines). Two for the per-core prime sieves and one
stack limit per core, three in the RSA assembly FFI, two in the standalone
flash-wipe tool.
flowchart TB
subgraph fw["firmware/src/main.rs + firmware/src/presence.rs"]
a["interrupt executor (×2)"]
b["Send for SendUsb"]
c["heap init"]
d["GPIO pin steal ×8 (presence, LED power, USR LED, display wake + CS/DC/RST/TP_RST)"]
d2["core0 stack limit (MSPLIM)"]
end
subgraph kg["firmware/src/core1.rs"]
e["per-core prime sieves (×2)"]
e2["core1 stack limit (MSPLIM)"]
end
subgraph asm["rsk-rsa"]
f["modexp / sign_crt / modexp_pub FFI (×3)"]
end
subgraph wipe["rsk-wipe"]
g["raw flash erase/program (×2)"]
end
The unsafe lives only in plumbing. None of it is in a parser, applet, crypto
wrapper, or the filesystem.
Firmware (firmware/src/main.rs, firmware/src/presence.rs)
1–2. The high-priority interrupt executor
#[interrupt]
unsafe fn SWI_IRQ_1() {
unsafe { EXECUTOR_HIGH.on_interrupt() }
}
USB and the transports run on an embassy InterruptExecutor so they preempt
long synchronous work (RSA keygen, flash GC) and keep the bus alive. The
handler itself is unsafe fn (hardware interrupt ABI). The on_interrupt()
contract (call only from the interrupt the executor was started on) is upheld
by construction: EXECUTOR_HIGH.start(SWI_IRQ_1) is the only starter and this
is the only caller.
Safe alternative: none; this is embassy's documented pattern for a second
executor.
Containment: two lines, no data touched.
3. unsafe impl Send for SendUsb
embassy_usb::UsbDevice is !Send only because it holds a list of
&mut dyn Handler control-request handlers. Our only stateful handler is a
zero-sized type whose state is Sync (critical-section-guarded statics). The
device is moved into exactly one task on the interrupt executor and never
touched from anywhere else: exclusive ownership after the move.
Safe alternative: none while the USB device must live on the interrupt
executor and embassy keeps the trait object !Send.
Containment: the wrapper is private, constructed once, and the invariant
(single task, single executor) is structural.
4. Heap initialization
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
A 128 KiB heap exists solely for rsk-rsa's big integers (num-bigint-dig,
the only allocating dependency). init's contract (call once, with exclusive access
to the region) is met: it runs once at the top of main, on a dedicated
static buffer used by nothing else.
Safe alternative: none; every embedded allocator initializes this way.
Containment: one call, before any allocation can happen.
5–12. GPIO pin type-erasure (presence button, LED power rail, USR-LED-off, display wake + control pins, ×8)
let any = unsafe { AnyPin::steal(pin) };
Eight build-configurable GPIOs are chosen by number at build time rather than as
a concrete PIN_n type, so each must be converted to embassy's type-erased
AnyPin: the optional PRESENCE_PIN=<gpio> presence button
(ButtonPresence::new_gpio, presence.rs), the optional LED_POWER_PIN
enable pin driven high to power a gated LED rail (the LED block in main.rs),
the optional USR_LED_PIN driven to a nuisance onboard LED's OFF level and held
(the boot block in main.rs), and — display builds only — the optional
WAKE_PIN button that wakes the panel from display sleep plus the panel's
CS/DC/RST/TP_RST control lines, all by board-config number, in the panel block
of main.rs. AnyPin::steal is unsafe because the caller must
guarantee unique ownership of that hardware pin — a match over p.PIN_0..=PIN_29
(as the LED data pin uses) is impossible here, since it would double-move the
peripheral set the LED block already claims.
Safe alternative: none for a runtime/number-selected GPIO; the safe
constructors require a statically known pin type.
Containment: each is gated by pin-range validation and the single-owner
invariant from main — none of the presence pin, the LED-power pin, the
USR-LED pin, the wake pin, nor the four panel control pins is ever handed to
another driver. Two checks hold that jointly: the LED data pin is resolved at
runtime from the host-writable phy record, and the filter chain in main drops a
value that names the presence pin, LED_POWER_PIN or USR_LED_PIN back to the
build default — so a host cannot aim the data pin at a pad another driver owns.
On top of that, compile-time assert!s reject a
build that collides LED_POWER_PIN or USR_LED_PIN with the LED data pin or a GPIO
PRESENCE_PIN (and refuse USR_LED_PIN outright on a display build, whose panel
owns those pads), rejects a WAKE_PIN in the LCD/touch range (10..=18),
and rejects any of CS/DC/RST/TP_RST/BL colliding with each other, with the
hard-wired PIO serial output (PIN_10/11) or I2C1 (PIN_6/7) lines, an enabled WAKE_PIN,
or LED_PIN/LED_POWER_PIN when their LED driver is built — a collision
silently drives one pad from two owners at runtime, so it is checked at build time.
Firmware dual-core keygen (firmware/src/core1.rs)
13–14. The per-core prime sieves
static mut CORE0_SIEVE: IncrementalSieve = IncrementalSieve::new();
static mut CORE1_SIEVE: IncrementalSieve = IncrementalSieve::new();
// …
let sieve = unsafe { &mut *core::ptr::addr_of_mut!(CORE1_SIEVE) }; // core1, in `search`
unsafe { (*core::ptr::addr_of_mut!(CORE1_SIEVE)).scrub() }; // core1, on the STOP edge
let sieve = unsafe { &mut *core::ptr::addr_of_mut!(CORE0_SIEVE) }; // core0, in `run_rsa_search`
The dual-core keygen runs one running small-prime sieve per core (each ~5 KiB
of residues, too large to live on core1's stack beside the Baillie-PSW
bignum frames, so they are static). Each is single-core-exclusive:
CORE0_SIEVE is taken &mut only inside run_rsa_search (core0),
CORE1_SIEVE only inside search (core1), and the two cores never touch the
same sieve. So the &mut never aliases and there is no cross-core race.
Each keygen calls scrub() through the reference before use, forcing a fresh
window; each core also scrubs its own sieve when its search ends, so the last
candidate — which is the prime that was found — does not sit here until the next
job. That end-of-search scrub deliberately stays on the owning core: STOP does
not wait for core1, so a scrub issued from core0 (e.g. on the reboot path) would
alias a live &mut while core1 is still inside try_candidate_le.
Safe alternative: none that is free. A Mutex/critical-section cell would
add a lock on a provably-uncontended access, and the sieve is reused across
jobs so it cannot be a stack local. (Edition-2024 forbids implicit &mut to a
static mut, hence the explicit addr_of_mut!.)
Containment: three call sites — two on core1 (search, end-of-search scrub), one
on core0; the partition (which core touches which sieve) is structural, and the data is non-secret (small-prime residues of
a candidate, scrubbed at the top of every keygen). A wrong residue can only let
a composite through to the strong-MR/Lucas test, which still rejects it.
15–16. The per-core stack limits (main.rs, core1.rs)
unsafe { cortex_m::register::msplim::write(&raw const _stack_end as u32) }; // core0, entering `main`
unsafe { cortex_m::register::msplim::write(stack_floor) }; // core1, entering `core1_main`
Core1's stack is an ordinary Stack<16384> array in .bss, so a push past its
end writes into whichever statics the linker placed below — silently, and with no
diagnostic. flip-link does not reach it: that guards core0's stack by moving it
to the bottom of RAM, and core1's is a different stack. ARMv8-M's MSPLIM traps
the stack-pointer decrement itself, which a read-only MPU guard band would not: a
frame big enough to step over the band writes past it and never faults, and the
modexp chain on this core reserves ~6 KiB at a time. Programming it is a bare
MSR that no safe API wraps.
Core0 gets the same instruction for a weaker reason, and it is worth being plain
about: flip-link already puts that stack at the bottom of RAM, so an overflow
runs off into unmapped space and faults with or without this — there is no gap
here to close. What the write buys is independence from the linker. Drop
flip-link and that floor disappears silently, along with the only thing keeping
this stack out of .bss; MSPLIM states the bound in code, where it can be read
and where removing it is a visible edit.
Safe alternative: none. cortex-m offers no checked form, and the MPU route is
both weaker (above) and more unsafe, not less.
Containment: one write per core, on the core that owns that stack, before anything has
pushed, with that stack's own base as the value — no legitimate frame sits below
it. A violation raises UsageFault/HardFault on core1. That is not graceful:
embassy_rp::multicore::pause_core1 spins unbounded on the inter-core FIFO, and a
core1 sitting in a fault handler never answers it, so the next flash write hangs
the device until it is replugged. That is the trade being made — a wedge a replug
clears, rather than a silent write into whatever .bss the linker put below,
issued by the routine that is at that moment generating and storing a key.
RSA assembly FFI (crates/rsk-rsa/src/lib.rs)
16–18. The modexp / CRT-sign calls
On-card RSA key generation needs hundreds of modular exponentiations over
1024–2048-bit candidates. The pure-Rust path was ~7× too slow on the
Cortex-M33 (minutes per key, CCID timeouts). The crate wraps the vendored
C+ARM-assembly routines behind three unsafe FFI calls — modexp_priv
(keygen), sign_crt (the CRT private-key operation) and modexp_pub (the
public-exponent side of blinding and the fault check) — each with fully owned,
length-checked buffers on both sides.
Safe alternative: tried (num-bigint). Functionally correct, unusably slow.
Containment: both ends fail closed. Key generation is KAT-gated — a power-on
known-answer self-test must pass or it refuses to run — and every signature is
Bellcore-fault-checked (out^e == base) by the caller, so a miscompiled or
corrupt routine cannot emit one. Inputs/outputs are fixed-size stack buffers
zeroized after use. On the host the crate substitutes a pure-Rust fallback, so
all host tests exercise the same API safely.
Flash wiper (rsk-wipe/src/main.rs)
19–20. Raw flash erase/program in a critical section
The wiper's entire job is to erase the flash the firmware lives on, from a
RAM-resident image. It calls the ROM flash-erase/program routines inside
critical_section::with(|_| unsafe { ... }): interrupts off, XIP disabled,
nothing else running.
Safe alternative: none; erasing the chip out from under yourself is
inherently unsafe and is the tool's purpose.
Containment: rsk-wipe is a separate opt-in UF2 you flash deliberately; it
never ships inside the firmware.
Build-time (not runtime)
crates/rsk-rsa/build.rs:unsafe { env::set_var(...) }forces the ARM cross-compiler for the vendored C. Build scripts are single-threaded at that point (the call is host-side, never in the image).firmware/build.rs:unsafe { env::set_var(k, v) }copies the selected board-config file's values (BOARD=<name>) back into the env before the build reads them. Same single-threaded host-side build-script context; never reaches the firmware image.- Edition-2024 declarations:
#[unsafe(link_section = ".start_block")]on the two bootrom image-definition statics andunsafe extern "C"on the linker-symbol/FFI declaration blocks. These mark declarations the compiler cannot check. The symbols are addresses read viaaddr_of!, never dereferenced as data.
What is not here
No unsafe in any parser, applet, crypto wrapper, or the flash filesystem.
The attacker-facing surface is entirely safe Rust, and cargo clippy -D warnings plus the fuzz targets (testing.md) keep it that way.