Skip to content

Feat/mcu platform foundation#18

Merged
0xEthamin merged 19 commits into
mainfrom
feat/mcu-platform-foundation
Jul 10, 2026
Merged

Feat/mcu platform foundation#18
0xEthamin merged 19 commits into
mainfrom
feat/mcu-platform-foundation

Conversation

@0xEthamin

Copy link
Copy Markdown
Member

No description provided.

0xEthamin added 18 commits July 6, 2026 16:23
Start the MCU platform with a clean, auditable TrustZone runtime partition for
the STM32U545. The board must be segmented before any application logic, so this
lays the workspace and the partition sequence first and proves it on host.

Add three crates next to the driver. platform is a no_std library that programs
the runtime isolation, secure and nonsecure are the two thin TrustZone binaries
that link to real thumbv8m ELF images. The C mcmse NSC veneer shim is deferred
until the linker and toolchain wiring lands.

The partition sequence is the heart of the change. apply_partition runs the
ordered bring-up from the secure side, enabling the GTZC clocks first, then the
SAU regions, the TZSC peripheral security, the MPCBB SRAM blocks, the GPIO
security, the GPDMA secure channels, the secure MPU, the development TZIC, and
the configuration locks last. Order is load bearing, so each hazard is encoded
as a test. The SE SPI link and the whole crypto block are marked secure, USB and
the touch sensor stay non-secure, the secret SRAM is secure, and the SE SPI DMA
channels are secure so a non-secure transfer cannot reach them. No irreversible
option byte is ever written. TZEN, RDP, BOOT_LOCK, WRP and the flash option
bytes are out of scope here and gated behind the hardware power-fault test.

The sequence runs against a RegisterBus abstraction so a host mock records the
exact ordered writes and the tests assert both order and value. The real MmioBus
does volatile MMIO and is the only place unsafe appears, each access carrying a
SAFETY note. The register definitions are hand rolled and minimal, every address
and bit traced to the Armv8-M manual, PM0264 or RM0456, and a separate test
module pins every constant to a hard coded literal so a wrong address cannot pass
unnoticed. apply_partition builds and validates the region table before touching
any register and fails closed, and the secure binary wedges forever rather than
hand off to the non-secure world on a partition error.

The workspace keeps unsafe_code forbidden by default. Only the three crates that
touch MMIO downgrade it to deny in their own lint table, so unsafe stays
quarantined to the crates that provably need it and every block is justified.

Gates pass on host and on thumbv8m. cargo check and cargo clippy are clean on
both targets, the platform suite is green with the partition order and the
constant pins covered, and both binaries link.
Make the non-secure world reachable and give it one controlled gateway into
the secure world, with no application logic yet. This turns the provisional
skeleton layouts into the real secure and non-secure memory split and proves a
secure gateway call links across the boundary.

Add the C mcmse non-secure-callable shim and wire it into the Cargo build. The
secure build script compiles secure_nsc.c with clang for the Cortex-M33 in cmse
mode through the cc crate, and the secure link emits a CMSE import library with
rust-lld so the non-secure image can resolve the gateway without any access to
the secure binary. clang was chosen so the whole compile, static-analysis and
link chain stays one LLVM toolchain, and the shim carries a clang-tidy cert and
hardening gate. The shim holds one coarse value-in value-out gateway,
patinakey_nsc_version, with zero logic, no secret, no pointer and no non-secure
function pointer by construction. The non-secure binary declares it and calls it
once so the link must resolve the gateway, which is the proof that the boundary
works.

Replace the provisional memory scripts with the real split. The secure image
owns flash bank one at the secure alias and the lower secure SRAM, the
non-secure image owns flash bank two and the upper SRAM, and the secure gateway
window sits at the top of the secure bank where the gateway stubs are pinned.
cortex-m-rt emits the gateway stub section without a fixed address, so the stub
is pinned to the window with a section-start link argument and kept against
section garbage collection with an explicit root. The two binaries build in two
stages because no Cargo dependency edge orders two binary crates, so the
non-secure build fails loudly with an actionable message if the secure import
library is missing, and the documented order is to build secure first.

Complete the secure side of the hand-off. After the partition applies, the
secure world points the non-secure vector table base at flash bank two, loads
the non-secure main stack pointer from the first vector, and branches to the
non-secure reset entry with bxns. The hand-off is reachable only on the
partition success path, so a partition fault still wedges the secure world and
never starts the non-secure world. Add the previously missing non-secure SAU
region for the RSS library so a non-secure call into it does not fault. The
region is scoped to the minimal RSS library non-secure function-pointer table
that the non-secure world must read to dispatch RSS library calls, taken from
the device function-pointer-table bounds per RM0456 section 3.6.2 and pinned by
a test. Least privilege is deliberate, the region excludes the bootloader code,
the one-time-programmable area, the reserved gap and the flash ECC test word, so
the non-secure world can read none of them.

Gates pass on host and on thumbv8m. cargo check and cargo clippy are clean on
both targets, the platform suite is green at thirty three tests with the new
system-memory region pinned, the two-stage build links both ARM ELF images, the
non-secure image resolves the gateway into the gateway window, the secure image
carries the secure gateway stub at the window with the expected sg and branch,
and the hand-off emits the non-secure stack-pointer write and bxns.
Program the banked secure-bank Armv8-M MPU so the secure world runs under write
xor execute and data execution prevention. Secure code is read-only and
executable, secure data and the secure peripheral aliases are read-write and
execute-never. This completes the runtime isolation of the platform on top of
the existing SAU and GTZC partition, and touches no irreversible or lifecycle
bit. It is runtime configuration only.

Add the mpu module to the platform crate. It mirrors the SAU region shape, an
MpuRegion built only through a validated constructor that rejects a misaligned
base or limit and an inverted range before any hardware write, with const RBAR
and RLAR encoders. The region table holds three regions over the addresses the
core actually emits in the secure state, the secure flash bank one alias for
code, the secure half of SRAM1 for data, and the secure peripheral alias range,
matching the secure linker layout rather than the alternate SRAM aliases. The
apply function builds and validates the table first, then disables the MPU,
writes the memory attributes with one Normal and one Device entry, programs each
region in region-number order, and enables the MPU with the hard-fault and
non-maskable-interrupt protection kept on. PRIVDEFENA is left clear, so there is
no background map and every secure access must hit one of the three regions or
fault, which is strict least privilege. The secure SRAM region deliberately
covers only the secure half of SRAM1, the memory the secure binary uses. SRAM2
and SRAM4 stay secure under the GTZC partition but are intentionally not mapped,
so the secure core cannot reach them at all until a future region is added with
its linker change. Every register address, field shift and attribute byte is
pinned to a hard-coded primary-source literal, and the sequence is host-tested
through the recording bus for the exact ordered write trace, the write xor
execute invariant, and the never-set background-map bit.

Wire the MPU into the secure binary as the last isolation step before the
hand-off. The secure entry reads the non-secure stack pointer and reset vector
and points the non-secure vector table base while the MPU is still off, then
applies the secure MPU, issues the data and instruction barriers so the new
configuration takes effect, and only then branches to the non-secure world with
bxns. No secure data access happens between the MPU enable and the branch, only
secure-flash instruction fetch and register operations, so the hand-off needs no
non-secure region in the secure MPU. A failure to apply the MPU wedges the
secure world and never starts the non-secure world, preserving the fail-closed
contract.

Gates pass on host and on thumbv8m. cargo check and cargo clippy are clean on
both targets, the platform suite is green at fifty tests, up from thirty three,
and the two-stage build links both ARM ELF images.
Wire the whole non-secure to secure to TROPIC01 chain so the board can be
flashed once and the chain watched end to end over defmt-rtt. The secure world
drives the secure element on SPI1 in a blocking transfer, and the non-secure
world reaches it through coarse value-out secure-gateway veneers. This is the
bridge to the on-silicon driver test. It touches no irreversible or lifecycle
bit, opens no secure channel, and moves no secret. It is runtime configuration
plus the transport, all host-tested, with the silicon proof gated on hardware.

Add the mcu-spi crate, a no_std blocking SPI1 driver for the secure world built
on the same register-access seam as the platform crate. A target-gated MMIO bus
is the only unsafe surface, and a scriptable test bus models the SPI status and
data registers so the transfer loop is fully host-tested. The crate exposes an
embedded-hal SpiDevice as a SPI bus plus a software chip-select on PA4, and a
blocking wait provider for the L1 readiness polling. The SPI pins, the alternate
function selection, the clock enables and the control-register setup follow the
board schematic and RM0456. The driver marks no security bit, the partition
already keeps SPI1 and the pins secure. Every register address and field is
pinned to a hard-coded primary-source literal.

The secure binary gains three extern entries that build a session-less device
handle and read the chip mode and the two firmware versions, mapping any error
to a fail-closed status word. The non-secure-callable shim forwards to them
through three value-out veneers that carry no pointer, secret, or non-secure
function pointer. The non-secure binary calls them at boot and logs the chip
mode and both firmware versions over defmt-rtt, so the full chain is observable
on the probe.

The SPI engine is flushed and re-armed at the start of every transaction and on
any error path, because only clearing the enable bit empties the receive FIFO,
so a faulted transfer cannot leave a stale byte that would shift every later
transfer by one byte until reset. The master automatic receive suspension is
enabled so a non-secure interrupt that preempts the secure transfer cannot
overrun the receive FIFO. Both behaviours are covered by host tests on the
scripted bus.
Add the first piece of the MCU self-update path, the single most brick-prone
component in the product. This is a pure no_std library that defines the format
of a signed firmware image and verifies it against an out-of-band pinned root
public key. It writes no flash, touches no option byte, sets no irreversible or
lifecycle bit, holds no unsafe, and fails closed on any anomaly. The bricking
critical math is not hand-rolled: the signature check is the audited ed25519
primitive, and the only new code is a bounded, fuzzed header parser in front of
it. Compiling a public root key into the firmware is fully reversible by a
reflash, it is not the irreversible pairing-key write.

Define a compact in-house image format rather than a heavier container, because
the signer and the verifier are both ours in a closed loop, so an external
tooling format buys nothing while its parsing surface and dead paths cost. The
layout is a fixed 24-byte header, then the payload, then a 64-byte Ed25519
signature that covers the header and the payload. The header carries a magic
tag, a parser schema version, a signature-algorithm byte, the firmware version,
a monotonic anti-rollback counter, the payload length, and two reserved bytes.
Every multi-byte field is little-endian, matching the Cortex-M33. Every offset
and constant is pinned to a literal in a test.

Verify fails closed at the first anomaly in a fixed order: a length floor, the
magic, the schema version, the algorithm byte, a reserved-bytes-must-be-zero
check, an exact total-length check that rejects any overflowing, short, long, or
trailing input, then verify_strict over the signed region. verify_strict is
chosen over verify so a low-order public key is rejected. The full header is
parsed up front into locals, and the result is built only after the signature
passes, so no field inside the signed region is ever exposed before
verification. The anti-rollback counter and the firmware version sit inside the
signed region, so neither can be downgraded without breaking the signature.

The signature algorithm is Ed25519 verified in software with a compiled-in
pinned key. The STM32U545 PKA does not support Edwards curves, so the verify is
software by construction (RM0456 section 53.3.4). A hardware P-256 path on the
PKA was weighed and set aside for this stage: its only gain is offload that does
not matter for a rare boot-time verify, and it would need an unwritten raw PKA
driver and a clocked peripheral, making the verifier depend on init order. The
algorithm byte sits inside the signed region so a future second algorithm can be
added without a format break, with the hard precondition that the signature
length, the key type, and the signed-region split must then be selected by that
byte rather than bolted onto the fixed Ed25519 offsets.

The dependency is ed25519-dalek 2.2 stable with default features off, verify
only, no heap. It pins sha2 0.10 next to the workspace sha2 0.11, a small and
accepted second hash copy on this path. The newer dalek release that unifies
sha2 was set aside because it pulls a far larger duplicate curve crate and puts
a release candidate on the bricking-critical path.

A libfuzzer target drives the verifier over arbitrary attacker bytes under a
fixed test key and must never panic. The host tests cover a valid round trip,
each error variant, the rejection of a tampered anti-rollback counter and a
tampered firmware version to prove both are bound by the signature, and a guard
that the fuzz seam key is genuinely accepted so the harness cannot silently
verify nothing.
…mocked flash seam

Introduce crates/fw-update, a no_std host-testable state machine for the MCU's
OWN firmware update, the most dangerous path in the product (the brick-the-fleet
risk). It depends on image-verify and is proven REVERSIBLY behind a mockable
flash seam: it emits no real flash write, no erase, no SWAP_BANK, no option-byte
write, no irreversible bit. The real volatile-flash MMIO driver and the
power-fault test are a separate hardware-gated step.

The dangerous operations exist only as trait methods. FlashSeam covers erase,
write_inactive_page, inactive_bank read-back, commit_swap, revert_swap, the
flash NVCNT read and bump, the persistent pending record and the boot-count
countdown. SeCounterSeam covers the regression-proof secure-element counter. The
only implementations in this crate are host mocks, so it is structurally
impossible to emit an irreversible operation from here, and the crate keeps
forbid(unsafe_code).

The inactive bank is the single source of truth. Chunks stream through
write_inactive_page into the inactive bank, an explicit begin(total_len)
completeness gate rejects a truncated transfer, then verify_and_accept reads the
bank back through inactive_bank and verifies exactly the bytes commit_swap will
make bootable. Verify and commit act on the same store by construction, so the
machine can never commit bytes it did not verify.

Commit is the atomic SWAP_BANK-as-commit model: the swap takes effect on the
next reset and the core never sees a half-swapped map, so a power loss before
the swap commits leaves the old bank booting (RM0456 section 7.5.8). The pending
record is Armed with the target bank before the swap is requested. On the next
boot the machine compares the running bank against the armed target: a match
proves the swap took effect and enters the confirm countdown, a mismatch means
the swap never happened so it keeps the old bank and arms no reverse swap. A new
image must self-confirm within a fixed number of boots or the machine reverts to
the previous bank. No power-loss boundary loses the old bank's bootability.

Anti-rollback is a two-gate model. Gate 1 is the flash NVCNT, compared at
install time against the image security counter, which sits inside the signed
region so it cannot be downgraded without breaking the signature (UM2851). Gate
2 is the secure-element monotonic counter, read and compared at accept time to
reject a regression the regression-proof way. The NVCNT is bumped LAST, only at
the terminal confirmed transition past the revert decision, so the rollback
counter and the revert path are mutually exclusive and the counter can never
rise above a bank that ends up booting.

Tested 29 ways on the host behind the mocks: the full happy path, a tampered
image rejected as a bad signature with no commit, a downgrade and a
secure-element regression both rejected with no commit, a truncated transfer
rejected, a confirmation timeout reverting to the old bank, a power loss whose
swap never took effect keeping the old bank without reverting into the
unverified one, and every seam fault failing closed. A detached libfuzzer target
drives the receive, verify, commit, boot and confirm path with attacker
controlled chunk offsets and lengths and never panics nor commits a rejected
image.
…a fidelity flash model

Turn the by-hand tracing of the power-loss windows into a machine-checked
property. Add a host-only fidelity model of the STM32U5 flash and an exhaustive
fault-injection harness over the dual-bank A/B update machine, so every
power-loss interleaving is proven to keep a bootable signature-verified image.
Pure host, no hardware, no irreversible operation, no real flash or option-byte
or SWAP_BANK write.

The earlier simple mock could not represent two silicon-only faults. Its page
write did a plain copy so it could raise a bit from 0 to 1, impossible on real
flash, and its commit flipped the running bank instantly while real silicon
stages SWAP_BANK and applies it only at the next reset. So a torn write that
survives as corruption and a power loss between arming the swap and the reset
were both invisible behind it.

The fidelity model reproduces the real semantics, each cited to RM0456 or
UM2851. A program clears bits from 1 to 0 only, an erase sets 0xFF, the program
unit is a quad-word (RM0456 sec 7.3.1, sec 7.3.7). A torn quad-word reads back
as detectable corruption, worst case a double-bit ECC fault, so the verifier
rejects the bank (RM0456 sec 7.3.11, sec 7.3.2). SWAP_BANK is staged and applied
only at a modelled reset, atomically, and a cut before the option load commits
keeps the OLD bank (RM0456 sec 7.4.2, sec 7.5.8). The NVCNT is a monotone burn
where a torn bump reads back at the old or new value, never below the old
(UM2851 Table 7, Table 8). The model states that the bit-level NVCNT encoding is
an inference consistent with stock MCUboot, not a direct manual quote, and that
the relied-on property is the floor. The model holds two physically separate
bank stores, so the update writes only the inactive bank and the OLD bank is
provably untouched.

The harness drives the whole flow begin to receive to verify-and-accept to
commit to the modelled reset to on-boot to confirm or revert, and injects a
power loss at every persistent-mutation boundary. A single global cut index
walks every mutation across the reset boundary, so a cut fires in confirm (the
SE spend, the pending clear, the NVCNT bump done last) and in revert (the
reverse-swap arm, the pending clear) after the reboot, not only before it. After
each cut the harness rebuilds the updater from the surviving persistent state, a
modelled reboot, runs the recovery to a settled fixed point, and asserts the
safety invariant: the OLD bank stays bootable until a swap is irrevocably
confirmed, the booting bank always verifies and an unverified image never
commits or boots, the NVCNT never rises above the booting bank counter, and no
recovery leaves a staged swap pointing at the unverified bank. A per-index
census asserts every reachable mutation index actually fired, the check that
catches a cut span with a gap. A separate run streams a bad-signature image and
proves it never reaches a confirmed swap at any cut. A dedicated case drops the
secure-element channel at the spend and proves the recovery spends the counter
exactly once with no half-confirmed strand.

The harness adds no parser of attacker bytes. The image bytes still go through
image-verify, which has its own fuzz target, and the chunk-offset path is
already exercised by the drive_machine fuzz target.

The hardware power-fault test plan that this model feeds is written as local
knowledge for the future on-bench run on the NUCLEO-U545RE-Q and the PatinaKey
board, which remains the hard gate before any RDP2 or BOOT_LOCK.
Builds the missing half of the verified update path. image-verify only
VERIFIES, so until now a signed image could be produced only by the inline
test fixtures. This adds the host tool that emits the signed-image format and
signs HEADER || PAYLOAD with an Ed25519 private key, so real images can flow
through the production signing path.

The on-wire layout now has a single source of truth: image-verify gains a
no_std header encoder behind a new `encode` feature, enabled only by the tool
and off in every firmware build. The tool composes the header through that
encoder, signs through a pluggable backend, appends the 64-byte signature, then
RE-VERIFIES its own output with verify_image so a malformed image can never
leave the tool.

The signer lives in a detached workspace under tools/image-signer (its own
empty workspace table) so it can never be pulled into the thumbv8m target build
or pollute the no_std firmware. It is a lib plus a thin bin. The lib exposes an
ImageSigner backend trait so a future hardware-token backend drops in without
rework, with only a SoftwareSigner implemented today. Argument parsing is
hand-rolled over std::env::args with no parsing dependency.

The private key is a raw 32-byte Ed25519 seed, never taken as a literal
argument, never hardcoded, never committed. It is supplied through --key-file,
either as a path (--key-file <PATH>) or piped from gpg --decrypt on stdin
(--key-file -), the cleanest production form because the seed never lands as a
cleartext file. The seed is held in a zeroizing buffer and is never printed.
The dev keypair already exists as the all-0x01 seed whose public key is the
pinned DEV_ROOT_KEY, so no new key material and no random generation are
introduced.

The sign subcommand takes a payload binary, the version fields, the security
counter, and a key source, and emits the signed image. An optional
--expect-pubkey flag fails closed before writing if the key does not match the
intended pinned public key, guarding the wrong-key-file operator error. The
derive-pubkey subcommand prints the public key to pin. Every bad input fails
closed with a clear message and a non-zero exit. Output to a closed pipe (for
example piping into head) exits cleanly rather than panicking, and the seed and
signer are dropped and zeroized before any such exit can fire.

An end-to-end test signs a payload with the dev seed, confirms verify_image
accepts the exact bytes, and drives them through the fw-update machine to a
confirmed swap, with a sibling proving a non-dev-signed image is rejected. The
signer crate takes fw-update and image-verify as path dev-dependencies for this
test, an edge that stays out of the MCU target build.

The host tool produces images and parses no attacker-controlled chip input, so
no new fuzz target is added. The chip-side parsing stays in image-verify which
is already fuzzed.
Implements the dual-bank A/B FlashSeam the fw-update machine consumes, as a
hand-rolled raw-MMIO STM32U545 embedded-flash driver, host-tested against a
faithful FLASH-controller model. This is the last host-buildable piece of the
firmware-update path, and it pins the real bank geometry from RM0456 ch.7.

The new crate crates/mcu-flash provides a FlashAccess register seam, a
target-gated MmioFlash (the crate's only unsafe, each block with a SAFETY note),
a regs module pinned to RM0456 literals, and Stm32FlashSeam mapping the seam to
the controller sequences: page erase, quad-word program (clearing bits only,
fail closed on PROGERR / SIZERR / WRPERR / PGSERR), the inactive bank read back
as the exact bytes a commit boots, the NVCNT and the pending / boot-count and a
new update-outcome record, and the full commit / revert option-byte path.

The driver is physical-bank-centric. SWAP_BANK remaps the bank addresses but the
BKER erase selector and the SECWM / WRP watermarks stay bound to the physical
bank (RM0456 sec 7.5.8), so one helper pairs a physical bank with the live
OPTR.SWAP_BANK to yield both the BKER bit and the mapped base, and erase and
program can never target different physical banks. The NVCNT and the metadata
live in a fixed physical Bank 1 band and re-derive their mapped address from
SWAP_BANK on every access, so they survive the swap. The host model holds two
physical stores whose address-to-store mapping flips at a modelled reset, so a
bank-divergence or a metadata-lost-across-swap is observable rather than hidden.
An integration test streams a real signed image through the public Updater,
models the swap, and reads the NVCNT back from the correct physical bank.

Brick-safety is by construction. The option-byte path (OPTR SWAP_BANK plus
OPTSTRT plus OBL_LAUNCH) is the real register sequence but lives only in the
target-gated MmioFlash, never compiles on the host, and is never invoked by any
build or test. Nothing auto-cables an irreversible operation. The real on-silicon
flip and the lifecycle lock stay gated on the hardware power-fault proof.

fw-update gains the UpdateOutcome seam record so a future boot-stage can signal a
non-silent auto-revert, and its fidelity model and mock track the same two-store
discipline. No behaviour of the A/B machine, the two-gate anti-rollback, or the
power-fault harness changes.
…lus RTT

Adds firmware/scripts/bringup.sh, a runner over probe-rs for the
TrustZone two-image build. It builds the secure then nonsecure crates in the
required order, flashes the secure image at the 0x0C00_0000 secure alias and the
nonsecure image at 0x0804_0000, then attaches the defmt-RTT decoder live, all in
one command. A detect subcommand does read-only probe and chip identification.

The chip target is STM32U545CEUx, confirmed against the probe-rs registry with a
flash and RAM map that matches the firmware memory layout exactly.

The attach steps carry a retry (ATTACH_RETRIES, default 4) because the ST-LINK
connect-under-reset sequence is intermittently flaky on the first attempt,
especially against a core spinning in a fault from a prior run.

It writes no option byte and never touches TZEN, SECWM,
RDP, BOOT_LOCK, WRP, or any irreversible state. Any lifecycle or provisioning
write stays a separate step.
…CFG2

configure_spi programmed SPI1 master mode by writing CR1.SSI (bit 12) and MASRX
(bit 8) AFTER the CFG2 write that sets MASTER (bit 22) and SSM (bit 26). With
software slave management the internal slave-select follows SSI, so the window
between those two writes left the master selected low, which arms a mode fault
(MODF, SR bit 9). A mode fault hardware-clears MASTER and SPE, dropping the
master back to slave mode where it never drives SCK, so every transfer stalled
and surfaced as L1 Bus upstream. On the newer U5 SPI a latched mode fault is
cleared only by writing 1 to IFCR.MODFC (bit 9), not the legacy
read-SR-then-write-CR1 sequence, and the per-transaction IFCR clear handled only
EOTC, TXTFC, and OVR, so a once-latched fault would never clear. The host
recording mock did not model MODF, so the fault was invisible to host tests
(silicon-only class).

Write CR1.SSI and MASRX BEFORE the CFG2 MASTER+SSM write, so the internal
slave-select is high before the master is ever selected and the mode-fault
window never opens. The SPE=0 precondition still comes first (CFG1 and CFG2 are
write-protected while SPE is set). Add SPI_IFCR_MODFC and OR it into the
per-transaction IFCR clear as defense in depth, so a latched fault is cleared
each transaction. This same configure_spi is the secure-world path, so the fix
also unblocks the secure world on silicon.

To make the silicon transition observable on the host, the recording bus now
models the one MODF transition (latch SR.MODF and clear MASTER+SPE when CFG2
selects MASTER+SSM while SSI is low, clear it on an IFCR.MODFC write). The model
mutates only the config-readback map, not the recorded write log, so the
order and value assertions stay honest. Three tests cover the write order (SSI
before MASTER), the absence of a latched fault after init, and the per-
transaction MODFC clear. The mode-fault test is non-vacuous: it fails against
the old ordering and passes against the fix. Bit positions SR.MODF and
IFCR.MODFC are both bit 9 per RM0456 Rev 7 sec 68.8.6 and sec 68.8.7.
Add a `se-fw-update` cargo feature (OFF by default) that updates the TROPIC01
secure element from factory 1.0.0 to CPU 2.0.0 / SPECT 1.0.0, driven from the
secure world over a new NSC veneer. When the feature is off the product
firmware is byte-unchanged, the update code and the vendor blobs compile out.

The secure veneer patinakey_nsc_se_fw_update drives the driver primitives step
by step (enter bootloader, update both bank pairs, exit to Application, verify
the running versions) so the returned status word names the exact failing step,
and the non-secure side logs the outcome plus the new versions over RTT. The
step granularity also reports, per step, whether a re-run recovers.

Brick-safety envelope, enforced and reviewed: the path issues no R_Config write
of any kind, so it cannot trip the factory-part permanent-Alarm-Mode errata, and
it never disables Maintenance, so an interrupted update stays in Maintenance mode
where a re-run re-flashes and recovers. The blobs are fed verbatim. The update
runs only when the feature is built on and the veneer is called, never on a
normal boot, the Rust cfg, the clang define, and the sgstubs EXTERN are all keyed
off the one feature.

The signed blobs are gitignored (vendor firmware),
a feature-on build requires them staged under crates/secure/fw_blobs/.
se_error_code collapsed every L1 fault to 0x11 and every L2 fault to 0x12,
which hid the real cause during silicon bring-up. Break them out so the
non-secure log names the exact fault: the L1 sub-variants get distinct
sentinels (Bus 0xE4, ChipBusy 0xE5, Alarm 0xE6, BadChipStatus 0xE7), the L2
transport faults get 0xE1 Crc / 0xE2 BadFrame / 0xE3 ShortFrame, and an L2
Status passes the raw chip status byte through. Also map the new
SeError::RebootUnsuccessful to 0x63.

This granularity is what surfaced the opaque bank-write failure as the readable
UnknownErr 0x7E that located the root cause. Shared by both the smoke and the
fw-update veneers so both encode errors the same way.
…ECC crypto on silicon

Add a feature-gated (se-session, OFF by default) bring-up surface of two
value-out NSC veneers driven from the secure world over SPI1.

Veneer 1, patinakey_nsc_se_session_ping (crates/secure/src/se_session.rs):
read STPUB from the chip X.509 cert store, open a Noise KK1 session against
the factory pairing slot 0 (the libtropic SDK default production SH0 keys,
public constants), run one encrypted L3 Ping with an exact echo compare,
tear down with the chip-notifying abort. Granular status word: bit 31 ERR
with the failing step (1 read-stpub, 2 open-session, 3 ping, 4 abort) and
the error code low byte (0xF0 reserved for an echo mismatch), bit 8 OK with
marker 0x51.

Veneer 2, patinakey_nsc_se_crypto (crates/secure/src/se_crypto.rs): verify
the full 4-cert chain from the cert store against the PINNED Tropic Square
Root CA v1 public key (serial 301, P-521 SEC1 point embedded, validated
on-curve), open the session with the VERIFIED STPUB via a shared
open_bringup_session helper, then under the session: 32 random bytes with a
non-constant sanity check, Ed25519 keygen in an erasable slot, EdDSA sign of
a fixed message, ed25519-dalek verify_strict of that signature on the host
side of the chip boundary, checked erase, ECDSA P-256 keygen + sign of a
fixed 32-byte digest with a shape check (no P-256 verifier linked, the
cryptographic verify completes with the hardware accelerator work), checked
erase, chip-notifying abort. Steps 1-11 in the status word, reserved codes
0xF1 EdDSA reject, 0xF2 random sanity, 0xF3 ECDSA shape, 0xF4 pubkey
length, OK marker 0x52. Tolerant pre-clean erases before both keygens
mirror the vendor reference sequence.

Wiring mirrors the se-fw-update pattern: feature-gated mods, build.rs
appends the sgstubs EXTERNs and the clang define, secure_nsc.c holds both
forwarders in one ifdef block, the nonsecure side decodes both words to
defmt-RTT and adds a bounded RTT drain spin before wfi (feature-on only).
The feature enables tropic01-driver/attestation and an optional
ed25519-dalek.

se_smoke.rs se_error_code gains a Chain arm (0x31) plus a totality wildcard
(0x7F, allow(unreachable_patterns)): cargo feature unification can compile
the driver Chain variant in independently of this crate's features, and a
crate cannot cfg on another crate's feature. This also turns the
whole-workspace target build green.

Proven on silicon, one flash, both veneers: "SE L3 session + Ping OK,
marker 0x51" then "SE crypto + attestation OK, marker 0x52", on chip FW
CPU 2.0.0 / SPECT 1.0.0.
…he secure to non-secure data-return channel

Extend the feature-gated se-session with two more
value-out NSC veneers and the secure MPU support they need.

se_persist (marker 0x53) proves the persistent-but-reversible SE
commands under a session: monotonic counter init, get, and update
including the at-zero boundary and an upward re-init, MAC-and-Destroy
re-initialization determinism, and an ECC key-store Ed25519
known-answer test (import the RFC 8032 test seed, compare the
chip-derived public key, verify the signature strict on-host, erase,
and confirm a post-erase sign fails). No provisioning write is issued.

se_readonly (marker 0x54) proves the remaining safe driver commands and
closes the P-256 crypto gap: pairing, config, and user-memory reads, a
single reversible user-memory erase on an empty slot, the chip identity,
and a P-256 generate, sign, and erase whose public key, signature, and
digest are exported so a host verifier confirms the signature
cryptographically.

The secure to non-secure data-return channel, the reason se_readonly
first HardFaulted: the secure MPU had no region covering non-secure RAM,
so the first veneer to write a non-secure buffer faulted (PRIVDEFENA is
0, no background map). Add a pinned fixed shared output buffer at the top
of non-secure SRAM (0x2002_FC00, 1 KiB) and a 4th secure MPU region
mapping exactly it Read-Write and execute-never, so the W^X invariant
holds. The veneer writes public data only to the fixed compile-time
address with no non-secure pointer.
This is the product's general data-return path for future NSC responses,
not a bring-up one-off.

Tooling: rename scripts/bringup.sh to scripts/bench.sh, it is now a
general flash, run, and detect runner rather than only a first-silicon
bring-up helper. Add a FEATURES passthrough so the SE proofs build with
one command, and force the shared CMSE import object to regenerate on a
feature or profile change through a stamp, a stale import object
otherwise breaks the non-secure link.
Docs:
- Firmware README: the real status (proven-on-silicon, host-validated, and the
  path to the next milestone), the repository layout, and the two-image build.
- Driver README: drop the "BETA - NOT silicon-validated" banner and add a
  per-command coverage matrix on two axes (silicon and model), with the brick
  semantics of the one-way provisioning writes documented.
- New docs/bench-runner.md: the bench.sh guide (sub-commands, the
  feature-to-marker map, the environment overrides, and the brick-safety
  guarantee).

CI:
- New embedded job: lint every embedded library on thumbv8m, then the two-image
  build across the feature matrix (default, se-session, se-fw-update), with the
  CMSE import object regenerated per feature and the vendor blobs fetched from
  the pinned libtropic. Compile only, no hardware.
- New image-signer job for the detached signing-tool workspace.
- The host job now covers the whole workspace, coverage near 93 percent lines.
- ci-local.sh mirrors the new stages.
Bump the report-channel and attestation dependencies.

Dependencies:
- defmt 1.0 -> 1.1 (now 1.1.1) and defmt-rtt 1.0 -> 1.3.
- ecdsa off its release candidate to the stable 0.17. p384 and p521 stay on
  their 0.14 release candidates until they ship stable on the same digest
  generation.

Supply chain (cargo-deny):
- Document a tracked, temporary ignore for RUSTSEC-2026-0110. bare-metal is
  unmaintained but reaches the tree through cortex-m 0.7.7.
  The ignore goes once a cortex-m release drops bare-metal.
- Mark the firmware-internal crates (secure, mcu-spi, mcu-flash, fw-update)
  publish = false, since only tropic01-driver is published. Their internal
  path dependencies are then legitimate and no longer read as wildcards.
- Allow wildcard path dependencies for these private crates.
Extract private helper functions from six functions SonarQube flagged over the
15 cognitive-complexity limit. Behavior is preserved exactly.

- The three SE bring-up veneers (se_readonly, se_persist, se_crypto) move their
  under-session steps into per-stage helpers returning a small Result. Each
  veneer now owns a single session teardown and one output commit. The status
  words, step codes, reserved codes, markers, record offsets, the
  erase-before-abort order, and the one unsafe write site are unchanged.
- The non-secure entry moves its feature-gated report blocks into helpers. The
  boot and log order, the shared-buffer read, the RTT drain spin, and the final
  idle loop are untouched.
- The dual-bank power-fault proof extracts its recovery loop and its census
  cross-product into helpers. The single global cut index, both the confirm and
  revert branches, the per-index census, and every invariant assertion keep the
  same effect.
@0xEthamin
0xEthamin force-pushed the feat/mcu-platform-foundation branch from dde034d to 5f391e1 Compare July 10, 2026 17:58
cortex-m 0.7.7 is the last published release and pulls the unmaintained
bare-metal 0.2.5 (RUSTSEC-2026-0110) non-optionally, plus duplicate copies
of embedded-hal 0.2.7, rustc_version 0.2.3, and semver 0.9.0. Our whole use
of it was a handful of core-instruction intrinsics. Replace them with
core::arch::asm in a new zero-dependency mcu-arch crate and provide the
critical-section impl defmt-rtt needs at link time ourselves. cortex-m-rt
stays. The five crates above leave the lock, and the temporary deny.toml
ignore for RUSTSEC-2026-0110 is removed.

- mcu-arch: wfi, dsb, isb, a cycle delay, and the PRIMASK helpers, each a
  byte-for-byte match of the cortex-m 0.7.7 reference, host stubs for x86_64.
- critical_section_impl: single-core PRIMASK save and restore, registered in
  the non-secure bin. Its SAFETY and TrustZone notes are grounded on PM0264
  (PRIMASK_NS masks secure exceptions too while AIRCR.PRIS is 0, an obligation
  recorded for the interrupt bring-up).
- Remove the non-secure RTT drain busy-wait. A bench run proved it useless:
  defmt-rtt blocks while the host is connected and SWD reads a sleeping core,
  so every line reaches the host without it.
- SPI1 DelayNs now fails closed rather than running an unbounded NOP spin.
  PM0264 sec 3.11.9 gives a NOP no timing floor, and no timer is wired yet.
  This drops the last NOP asm block from the firmware.
- asm gate: mcu-arch inline asm is cfg(target_os = none), so no host lint
  sees it. scripts/asm-gate.sh disassembles the built images and asserts the
  cpsid/cpsie masking pair, the dsb/isb/bxns boot order, and the delay
  countdown loop. Both scripts/ci-local.sh and the GitHub embedded job run
  the one shared script, so the local mirror and the pipeline cannot diverge.
- publish = false on platform and image-verify closes a latent cargo-deny
  wildcard-path failure the moment either gains a path dependency.

Runtime behaviour of the default product image is unchanged. The removal was
proven on silicon (the se-session bring-up markers 0x51 through 0x54 all
logged with the change applied).
@0xEthamin
0xEthamin force-pushed the feat/mcu-platform-foundation branch from 5f391e1 to 4d8e6d2 Compare July 10, 2026 17:59
@0xEthamin
0xEthamin merged commit bfc484d into main Jul 10, 2026
13 checks passed
@0xEthamin
0xEthamin deleted the feat/mcu-platform-foundation branch July 11, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant