From 48cc2a7c5cb327331d8a09220c6e31427fb96cfa Mon Sep 17 00:00:00 2001 From: 0xEthamin Date: Sat, 8 Aug 2026 09:20:36 +0200 Subject: [PATCH 1/2] firmware: dual-bank A/B update, P-256 signed images, immutable boot stage Bring up the whole field-update path: a signed image, an immutable first-stage boot decision, a register-level flash driver, and the host tooling that lays down a bank signed by a hardware-held root key. - Signature: ECDSA P-256 over SHA-256 replaces Ed25519. The A/B split makes the image non-contiguous, which pure Ed25519 cannot verify without a large RAM copy, so verify runs prehash-native over segments. One verifier ships, the Ed25519 id survives only as a downgrade guard, and low-s is enforced. - Layout, identical in both banks so no security window reprograms on a swap: metadata 0-1, immutable boot stage 2-8, descriptor 9, secure app 10-19 with the CMSE veneer on page 19, non-secure app 20-31. Both link scripts move onto their bands. - New crate boot-stage returns exactly one decision (boot, revert, wedge) and bumps the anti-rollback counter LAST. It sits outside the image band, because the new bank's boot stage is what runs the revert. Its pinned anchor product_root_key.sec1 is a PUBLIC key whose private half stays in a hardware token, and the build pins production with no dev-key fallback. - mcu-flash drives each page through the controller and alias matching its watermark label. The power-fault census moves here onto the register-level model, and the fw-update fidelity and power_fault modules are deleted. - platform: the non-secure flash region now spans the whole alias, since an uncovered address is fixed secure and that silently broke writes to the active bank's non-secure pages. The secure MPU grows to seven regions, adding a swap-derived metadata region and two inactive-bank updater windows starting at page 9. - image-signer gains bank assembly and an offline-signature flow, so the private key never enters the tool. Every path compares the signing key to the expected key before laying down a byte. - scripts/ab-bench.sh automates the bench round trip, brick-safe by construction. The TROPIC01 model pin moves into the driver's own install-model.sh, with a checksum and a post-install version check. --- .cargo/config.toml | 42 +- .github/workflows/ci.yml | 21 +- .gitignore | 13 +- Cargo.lock | 34 +- Cargo.toml | 12 +- README.md | 2 +- crates/boot-stage/Cargo.toml | 50 + crates/boot-stage/README.md | 23 + crates/boot-stage/build.rs | 34 + crates/boot-stage/memory.x | 26 + crates/boot-stage/product_root_key.sec1 | 2 + crates/boot-stage/src/decision.rs | 219 ++++ crates/boot-stage/src/entry.rs | 110 ++ crates/boot-stage/src/glue.rs | 167 +++ crates/boot-stage/src/health.rs | 123 ++ crates/boot-stage/src/key.rs | 43 + crates/boot-stage/src/main.rs | 55 + crates/boot-stage/src/mock.rs | 364 ++++++ crates/boot-stage/src/real.rs | 106 ++ crates/boot-stage/src/seam.rs | 122 ++ crates/boot-stage/src/secwm.rs | 84 ++ crates/boot-stage/src/tests.rs | 746 ++++++++++++ crates/fw-update/Cargo.toml | 8 +- crates/fw-update/fuzz/Cargo.lock | 303 +++-- .../fuzz/fuzz_targets/drive_machine.rs | 2 +- crates/fw-update/src/fidelity.rs | 757 ------------ crates/fw-update/src/lib.rs | 49 +- crates/fw-update/src/machine.rs | 397 +++++-- crates/fw-update/src/mock.rs | 82 +- crates/fw-update/src/power_fault.rs | 869 -------------- crates/fw-update/src/seam.rs | 105 +- crates/fw-update/src/test_fixtures.rs | 83 ++ crates/fw-update/src/tests.rs | 222 ++-- crates/image-verify/Cargo.toml | 13 +- crates/image-verify/fuzz/Cargo.lock | 303 +++-- .../fuzz/fuzz_targets/verify_image.rs | 13 +- crates/image-verify/src/encode.rs | 64 +- crates/image-verify/src/error.rs | 35 +- crates/image-verify/src/format.rs | 91 +- crates/image-verify/src/lib.rs | 690 ++++------- crates/image-verify/src/segments.rs | 222 ++++ crates/image-verify/src/tests.rs | 620 ++++++++++ crates/mcu-flash/Cargo.toml | 5 +- crates/mcu-flash/src/bus.rs | 44 +- crates/mcu-flash/src/driver.rs | 657 ++++++++--- crates/mcu-flash/src/driver_tests.rs | 145 ++- crates/mcu-flash/src/lib.rs | 52 +- crates/mcu-flash/src/machine_tests.rs | 226 ++-- crates/mcu-flash/src/model.rs | 858 +++++++++++--- crates/mcu-flash/src/mpu_containment_tests.rs | 234 ++++ crates/mcu-flash/src/power_fault_tests.rs | 1034 +++++++++++++++++ crates/mcu-flash/src/regs.rs | 299 ++++- crates/mcu-flash/src/regs_pin_tests.rs | 85 +- crates/nonsecure/memory.x | 16 +- crates/platform/src/map.rs | 177 ++- crates/platform/src/mpu.rs | 232 +++- crates/secure/build.rs | 10 +- crates/secure/memory.x | 35 +- crates/secure/src/main.rs | 33 +- .../tropic01-driver/scripts/install-model.sh | 71 ++ crates/tropic01-driver/scripts/model-itest.sh | 29 +- crates/tropic01-driver/tests/oracle/README.md | 4 +- docs/ab-bench.md | 54 + scripts/ab-bench.sh | 249 ++++ scripts/ci-local.sh | 10 + tools/image-signer/Cargo.lock | 241 +++- tools/image-signer/Cargo.toml | 27 +- tools/image-signer/src/bank.rs | 747 ++++++++++++ tools/image-signer/src/external.rs | 696 +++++++++++ tools/image-signer/src/lib.rs | 540 +++++++-- tools/image-signer/src/main.rs | 904 ++++++++++++-- tools/image-signer/tests/cli.rs | 855 ++++++++++++-- tools/image-signer/tests/end_to_end.rs | 57 +- 73 files changed, 12195 insertions(+), 3757 deletions(-) create mode 100644 crates/boot-stage/Cargo.toml create mode 100644 crates/boot-stage/README.md create mode 100644 crates/boot-stage/build.rs create mode 100644 crates/boot-stage/memory.x create mode 100644 crates/boot-stage/product_root_key.sec1 create mode 100644 crates/boot-stage/src/decision.rs create mode 100644 crates/boot-stage/src/entry.rs create mode 100644 crates/boot-stage/src/glue.rs create mode 100644 crates/boot-stage/src/health.rs create mode 100644 crates/boot-stage/src/key.rs create mode 100644 crates/boot-stage/src/main.rs create mode 100644 crates/boot-stage/src/mock.rs create mode 100644 crates/boot-stage/src/real.rs create mode 100644 crates/boot-stage/src/seam.rs create mode 100644 crates/boot-stage/src/secwm.rs create mode 100644 crates/boot-stage/src/tests.rs delete mode 100644 crates/fw-update/src/fidelity.rs delete mode 100644 crates/fw-update/src/power_fault.rs create mode 100644 crates/fw-update/src/test_fixtures.rs create mode 100644 crates/image-verify/src/segments.rs create mode 100644 crates/image-verify/src/tests.rs create mode 100644 crates/mcu-flash/src/mpu_containment_tests.rs create mode 100644 crates/mcu-flash/src/power_fault_tests.rs create mode 100755 crates/tropic01-driver/scripts/install-model.sh create mode 100644 docs/ab-bench.md create mode 100755 scripts/ab-bench.sh create mode 100644 tools/image-signer/src/bank.rs create mode 100644 tools/image-signer/src/external.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 2a3d1b3..4e3c0a3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,42 +1,2 @@ -# Workspace cargo configuration. -# -# DESIGN CHOICE (host-testable lib + embeddable bins): -# We deliberately do NOT set a global `build.target`. The default target stays -# the host (x86_64-unknown-linux-gnu) so that: -# - `cargo test -p platform` runs the partition-sequence tests natively on the -# host (the platform lib is pure logic over a register-bus abstraction and -# MUST stay host-testable for the 100%-coverage goal), and -# - `cargo check` over the whole workspace stays a host check. -# The two binary crates (`secure`, `nonsecure`) gate their cortex-m-rt content -# behind `cfg(target_os = "none")`, so they compile to an empty host stub on the -# host and to real firmware only when built for the embedded target: -# cargo build --target thumbv8m.main-none-eabihf -# -# Pinning `build.target = thumbv8m...` globally WOULD break host `cargo test` for -# the lib (no std test harness on a bare-metal target), which is why we leave the -# default host target and document the explicit `--target` invocation here. -# -# The rustflags below are TARGET-SCOPED: they apply ONLY when building for the -# embedded target, never to the host build. They wire cortex-m-rt's linker script -# (link.x), which in turn pulls each bin crate's provisional memory.x (emitted by -# that crate's build.rs). The memory layout is PROVISIONAL (see each memory.x): the -# real secure / non-secure split lands with the NSC-shim wiring (C toolchain + -# linker). - [target.thumbv8m.main-none-eabihf] -rustflags = ["-C", "link-arg=-Tlink.x"] - -# Convenience alias so the embedded build. -# -# TWO-STAGE MCU BUILD: there is no Cargo dependency edge between the `secure` and -# `nonsecure` bin crates, so cargo does NOT order them. The non-secure link needs -# the CMSE import object produced by linking the secure bin, so build the secure -# crate first, then the non-secure crate: -# cargo build -p secure --target thumbv8m.main-none-eabihf -# cargo build -p nonsecure --target thumbv8m.main-none-eabihf -# A plain whole-workspace `build-mcu` may race (NS link before the secure import -# object exists, or against a stale one). nonsecure/build.rs then fails loudly with -# an actionable message. The aliases below stay as-is for single-crate builds. -[alias] -build-mcu = "build --target thumbv8m.main-none-eabihf" -check-mcu = "check --target thumbv8m.main-none-eabihf" +rustflags = ["-C", "link-arg=-Tlink.x"] \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e761716..c7ea49f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,10 +12,11 @@ # An asm gate disassembles the built images and asserts the hand-rolled # inline asm compiled to the right instructions, since no host lint sees it. # - The host signing tool is a detached workspace, so it has its own job. -# - Live model integration: the coverage job clones the official TROPIC01 model -# (libtropic, pinned to a tag + commit) and drives tropic01-driver against it -# end-to-end (real handshake + AES-GCM). These run under coverage so the -# library paths they exercise count. The test-harness files are excluded. +# - Live model integration: the coverage job clones libtropic (pinned to a tag + +# commit) for the model config, installs the ts-tvl wheel pinned by +# crates/tropic01-driver/scripts/install-model.sh, and drives tropic01-driver +# against it end-to-end (real handshake + AES-GCM). These run under coverage so +# the library paths they exercise count. The test-harness files are excluded. # - cargo-deny BLOCKS on findings (it now also covers the RustSec advisory DB # that cargo-audit used to check, making a separate audit job redundant): a # known vulnerability, a banned or yanked crate, a disallowed license, or an @@ -43,9 +44,9 @@ permissions: env: CARGO_TERM_COLOR: always - # Official TROPIC01 model (libtropic), pinned to a release tag and its commit. - # Shared by the coverage job (the model) and the embedded job - # (the se-fw-update vendor blobs). + # Official libtropic, pinned to a release tag and its commit. Shared by the + # coverage job (the TROPIC01 model config and venv layout) and the embedded + # job (the se-fw-update vendor blobs). LIBTROPIC_REF: v4.0.0 LIBTROPIC_SHA: 756c8ee898ed61b12272ecb22b213edf97aab751 @@ -269,7 +270,7 @@ jobs: - name: Configure model location run: echo "LIBTROPIC=$RUNNER_TEMP/libtropic" >> "$GITHUB_ENV" - - name: Clone the TROPIC01 model (libtropic, pinned) + - name: Clone the model config (libtropic, pinned) run: | set -euo pipefail git clone --depth 1 --branch "$LIBTROPIC_REF" \ @@ -280,8 +281,8 @@ jobs: exit 1 fi - - name: Install the TROPIC01 model (ts-tvl) - run: "$LIBTROPIC/scripts/tropic01_model/install_linux.sh" + - name: Install the TROPIC01 model (ts-tvl, pinned) + run: crates/tropic01-driver/scripts/install-model.sh # Coverage over the hermetic suite PLUS the live model integration tests # (tests/model_itest.rs, behind `model-itest`): the driver runs its real diff --git a/.gitignore b/.gitignore index ab6b15a..7daef7d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,12 +9,7 @@ # TROPIC01 model server runtime artifact (default config-out dump) .model_config_save.yaml -# Signing key material and signing artifacts: never commit a private key. -# Key material lives under a keys/ directory OR carries a key extension, and -# exactly that set is ignored. The patterns stay narrow on purpose: a broad -# pattern like *seed* would also swallow a future source file such as -# seed_tests.rs, so each form is listed explicitly. The detached signer build -# dir is kept out too. +# Signing key material and signing artifacts. /keys/ **/keys/ *.seed @@ -29,3 +24,9 @@ seed.bin # Vendor SE firmware-update blobs (Tropic Square signed, from the libtropic SDK) crates/secure/fw_blobs/ + +# Bank / signing / flashing artifacts. +/tools/image-signer/*.bin +/tools/image-signer/*.hex +/tools/image-signer/sig.raw +/tools/image-signer/manifest.txt diff --git a/Cargo.lock b/Cargo.lock index 82a813b..e64ec40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,20 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "boot-stage" +version = "0.0.1" +dependencies = [ + "cortex-m-rt", + "fw-update", + "image-verify", + "mcu-arch", + "mcu-flash", + "p256", + "panic-halt", + "sha2", +] + [[package]] name = "cc" version = "1.2.66" @@ -364,8 +378,8 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" name = "fw-update" version = "0.0.1" dependencies = [ - "ed25519-dalek", "image-verify", + "p256", ] [[package]] @@ -412,7 +426,8 @@ dependencies = [ name = "image-verify" version = "0.0.1" dependencies = [ - "ed25519-dalek", + "p256", + "sha2", ] [[package]] @@ -438,9 +453,9 @@ version = "0.0.1" name = "mcu-flash" version = "0.0.1" dependencies = [ - "ed25519-dalek", "fw-update", "image-verify", + "p256", ] [[package]] @@ -473,6 +488,19 @@ dependencies = [ "autocfg", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", +] + [[package]] name = "p384" version = "0.14.0" diff --git a/Cargo.toml b/Cargo.toml index 5633e7c..2d104e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/nonsecure", "crates/image-verify", "crates/fw-update", + "crates/boot-stage", ] [workspace.package] @@ -36,13 +37,12 @@ ecdsa = { version = "0.17", default-features = false } p384 = { version = "0.14", default-features = false, features = ["ecdsa"] } p521 = { version = "0.14", default-features = false, features = ["ecdsa"] } -# Ed25519 verification of the signed firmware-image header (image-verify crate). -# Verify-only on the device: the pinned root public key is the trust input and -# no secret key runs here, so the failure modes are correctness, not -# side-channel leakage. default-features = false keeps it no_std and heap-free. -ed25519-dalek = { version = "3.0", default-features = false } +# ECDSA P-256 over SHA-256: the firmware-image signature algorithm +# (image-verify, and the host signing tool). +p256 = { version = "0.14", default-features = false, features = ["ecdsa"] } -# MCU platform / firmware-binary dependencies +# Ed25519 for the TROPIC01 Ed25519 signatures (the SSH ed25519-sk and PGP path). +ed25519-dalek = { version = "3.0", default-features = false } # cortex-m-rt: the de-facto reset-vector + .data/.bss init + linker glue for # Cortex-M. Provides #[entry] and the link.x script the two bins build against. diff --git a/README.md b/README.md index 5ac6dc7..1ad5396 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The workspace is a mix of one host-testable library published to crates.io, the | [`platform`](crates/platform) | library | TrustZone partition logic (SAU / GTZC / secure MPU), the address map, and the non-secure pointer checks. Host-tested | | [`mcu-spi`](crates/mcu-spi) | library | The SPI1 MMIO master driving the TROPIC01 | | [`mcu-flash`](crates/mcu-flash) | library | The on-target dual-bank flash driver behind the update seam | -| [`image-verify`](crates/image-verify) | library | The signed firmware-image header verifier (Ed25519) | +| [`image-verify`](crates/image-verify) | library | The signed firmware-image verifier (ECDSA P-256 over SHA-256, streamed across a segmented image) | | [`fw-update`](crates/fw-update) | library | The MCU A/B update state machine (verify then swap-as-commit) | | [`secure`](crates/secure) | binary (TZ-S) | The secure-world image: partition bring-up, the SE driver, and the CMSE non-secure-callable veneers | | [`nonsecure`](crates/nonsecure) | binary (TZ-NS) | The non-secure image: the entry that calls the veneers and reports over defmt-RTT | diff --git a/crates/boot-stage/Cargo.toml b/crates/boot-stage/Cargo.toml new file mode 100644 index 0000000..f3d9e03 --- /dev/null +++ b/crates/boot-stage/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "boot-stage" +edition.workspace = true +version.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +license.workspace = true +publish = false +description = "STM32U545 immutable first-stage boot code: verifies the running bank, confirms or reverts a pending A/B swap, then hands off to the secure app." + +[dependencies] +# The signed-image verifier. The boot stage verifies the RUNNING bank's four +# image segments against the pinned product root key before it hands off. +image-verify = { path = "../image-verify" } +# The shared A/B vocabulary: BankId, PendingFlag, UpdateOutcome, FlashError. The +# real flash driver (mcu-flash) speaks these, so the boot decision and the +# updater agree on the persistent record encoding. +fw-update = { path = "../fw-update" } + +[dev-dependencies] +# encode_header mints signed test descriptors so the host tests never hardcode +# the header layout. Feature unification: the product build (no dev-deps) keeps +# image-verify without `encode`, the test build turns it on. +image-verify = { path = "../image-verify", features = ["encode"] } +# Mints signed fixtures: the tests sign a synthetic image with the known bring-up +# scalar so the four-segment health check genuinely accepts it. +p256 = { workspace = true } +# Derives the bring-up test signing key (SHA-256 of the phrase) for the signed +# health-check fixtures in mock.rs. +sha2 = { workspace = true } + +# Embedded-only wiring. On the host the bin is an empty main, so these compile +# only for the target. The real flash driver, the core barriers, the reset +# vector, and the halting panic handler are all untestable silicon glue. +[target.'cfg(target_os = "none")'.dependencies] +mcu-flash = { path = "../mcu-flash" } +mcu-arch = { path = "../mcu-arch" } +cortex-m-rt = { workspace = true } +panic-halt = { workspace = true } + +# Lint quarantine: the target glue holds the untestable MMIO wiring (VTOR write, +# the MSP-load-and-branch hand-off, the real driver port). +# The pure boot logic has no unsafe. +[lints.rust] +unsafe_code = "deny" +missing_docs = "warn" + +[lints.clippy] +all = "deny" diff --git a/crates/boot-stage/README.md b/crates/boot-stage/README.md new file mode 100644 index 0000000..3594c81 --- /dev/null +++ b/crates/boot-stage/README.md @@ -0,0 +1,23 @@ +# boot-stage + +Immutable first-stage boot code for the A/B update model. Runs from pages 2-8 of +whichever bank the hardware boots (SECBOOTADD0 = 0x0C004000, selected by +SWAP_BANK). It reads the image DESCRIPTOR on page 9 of the active bank (the signed +header and signature), verifies the four logical segments with the P-256 verifier +(header, secure payload, non-secure payload, signature), then jumps to the secure +app link origin 0x0C014000, and drives commit/revert. + +The boot DECISION is a state machine (`decision.rs`) over the persistent +state (running bank, pending record, NVCNT, image health). It is proven +exhaustively on the host, including a power-cut census at every persistent +mutation boundary. The silicon glue (the real flash driver port, the register +reads, the secure-to-secure jump) is thin and target-only (`entry.rs`, `real.rs`). +The anti-rollback NVCNT bump is done last and is mutually exclusive with a revert. + +The FLASH origin/length here MUST agree with the layout table: + + pages 0-1 0x0C000000 16K boot metadata (physical Bank 1 only) + pages 2-8 0x0C004000 56K boot stage (this crate, IMMUTABLE) + page 9 0x0C012000 8K image descriptor (header [0:24], signature [24:88]) + pages 10-19 0x0C014000 80K secure app + NSC veneer + pages 20-31 0x08028000 96K non-secure app diff --git a/crates/boot-stage/build.rs b/crates/boot-stage/build.rs new file mode 100644 index 0000000..912ef7e --- /dev/null +++ b/crates/boot-stage/build.rs @@ -0,0 +1,34 @@ +//! Linker wiring for the immutable boot-stage binary. +//! +//! For the embedded target only, emit `memory.x` (the boot-stage FLASH / RAM +//! layout, pages 2-8 at 0x0C004000) onto the linker search path so cortex-m-rt's +//! `link.x` composes with it. On the host the bin is an empty stub, so this is a +//! no-op there. + +use std::env; +use std::error::Error; +use std::fs; +use std::path::PathBuf; + +fn main() -> Result<(), Box> +{ + println!("cargo:rerun-if-changed=memory.x"); + println!("cargo:rerun-if-changed=build.rs"); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "none" + { + return Ok(()); + } + + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + fs::write(out_dir.join("memory.x"), include_bytes!("memory.x"))?; + println!("cargo:rustc-link-search={}", out_dir.display()); + // Disable section page-alignment so the linker emits no header-carrying + // segment below FLASH ORIGIN. Without it rust-lld aligns the first segment + // down to the 64 KB page (0x0C000000), placing a phantom ELF-header LOAD in + // the metadata band (pages 0-1). --nmagic keeps every loadable byte inside the + // boot-stage band [0x0C004000, 0x0C012000). + println!("cargo:rustc-link-arg=--nmagic"); + Ok(()) +} diff --git a/crates/boot-stage/memory.x b/crates/boot-stage/memory.x new file mode 100644 index 0000000..e6602b3 --- /dev/null +++ b/crates/boot-stage/memory.x @@ -0,0 +1,26 @@ +/* Immutable boot-stage memory layout for cortex-m-rt's link.x. + * + * FLASH is pages 2-8 of a 256 KB bank at the low secure alias 0x0C00_4000, + * LENGTH 56 KB (7 pages x 8 KB). This band is IMMUTABLE: it is OUTSIDE the A/B + * image band (pages 9-31), so an update can neither program it (the updater MPU + * regions start at page 9) nor erase it (WRP guards the erase, the only op the + * MPU cannot see). Its vector base is SECBOOTADD0 = 0x0C00_4000, written once at + * provisioning and selected on every reset. SWAP_BANK remaps which physical bank + * sits at this low alias, so the boot stage runs from whichever bank booted. + * + * Pages 0-1 (boot metadata, 0x0C00_0000, 16 KB) sit BELOW this origin and are + * pinned to physical Bank 1, so they are never linked into the boot stage. + * + * RAM is the lower 128 KB of SRAM1 at 0x2000_0000, the secure RAM half, matching + * the secure app crate and the SAU region 2 / MPCBB1 split in platform map.rs. + * + * The boot-stage crate consumes this script (bank choice, commit/revert, image + * health, anti-rollback) and is fully built. This layout is fixed so the address + * map stays stable across the A/B work. RM0456 sec 7.5.8 (identical layout per + * bank) and Table 26 (SECBOOTADD0). + */ +MEMORY +{ + FLASH (rx) : ORIGIN = 0x0C004000, LENGTH = 56K + RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K +} diff --git a/crates/boot-stage/product_root_key.sec1 b/crates/boot-stage/product_root_key.sec1 new file mode 100644 index 0000000..d05b3aa --- /dev/null +++ b/crates/boot-stage/product_root_key.sec1 @@ -0,0 +1,2 @@ + +=VrS"k c^ ]HO7˛WYz _41LM3 \ No newline at end of file diff --git a/crates/boot-stage/src/decision.rs b/crates/boot-stage/src/decision.rs new file mode 100644 index 0000000..9d8518d --- /dev/null +++ b/crates/boot-stage/src/decision.rs @@ -0,0 +1,219 @@ +//! The boot decision state machine. +//! +//! [`decide`] maps the persistent boot state (running bank, pending-confirm +//! record, NVCNT, and running image health) to a [`BootDecision`]. +//! +//! # The three situations +//! +//! - No pending update (`PendingFlag::None`): the running bank is confirmed. +//! Boot it if healthy, else wedge. +//! - A swap took effect (`Armed(target)`, running == target): the running bank is +//! the freshly swapped-to new bank and a confirm is owed. Healthy and not +//! rolled back means confirm then boot, otherwise revert. +//! - A swap never took effect (`Armed(target)`, running != target): a power loss +//! before the option load committed left the old bank booting (RM0456 sec +//! 7.5.8: the CPU never sees a half-swapped map), or an auto-revert already +//! landed back on it. The stale record is cleared and the old bank boots. +//! +//! # Anti-brick ordering +//! +//! A confirm clears the outcome, clears the pending record, then bumps the NVCNT +//! last (see [`BootPlan`]). A revert never bumps the NVCNT, and each [`decide`] +//! returns exactly one decision, so a bank can never gain an NVCNT floor and then +//! be reverted away. A cut between clearing pending and the bump leaves +//! `PendingFlag::None` with a lagging NVCNT, which the no-pending arm heals by +//! advancing the NVCNT to the running image's counter. + +use fw_update::BankId; +use fw_update::PendingFlag; + +use crate::health::ImageHealth; + +/// Why the boot stage refuses to hand off. A wedge halts fail-closed: no image is +/// booted and no swap is armed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WedgeReason +{ + /// No bootable image: the running bank did not verify and there is no other + /// bank to fall back to. + NoBootableImage, + /// The running bank verified but its security counter is below the NVCNT + /// anti-rollback floor. + RolledBack, + /// The provisioned watermarks did not match the expected secure layout. + SecwmMismatch, + /// A persistent record or a register was unreadable, or the partition + /// (DUALBANK / TZEN) was not sane. + Unreadable, +} + +/// The persistent bookkeeping a Boot decision applies before hand-off. +/// +/// Applied in field order: clear the outcome, clear the pending record, then bump +/// the NVCNT last. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct BootPlan +{ + /// Clear the update-outcome record: a fresh confirm supersedes any prior + /// auto-revert note. + pub(crate) clear_outcome: bool, + /// Clear the pending-confirm record to `None`. This is the commit point that + /// ends "confirm owed". + pub(crate) clear_pending: bool, + /// Bump the NVCNT to this value, done last. `None` means no bump is owed. + pub(crate) advance_nvcnt: Option, +} + +/// What the boot stage does after reading its inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BootDecision +{ + /// Hand off to the running bank's app after applying the [`BootPlan`]. + Boot(BootPlan), + /// Re-arm SWAP_BANK toward the inactive (old) bank and record the auto-revert. + /// Never bumps the NVCNT. + Revert, + /// Halt fail-closed. + Wedge(WedgeReason), +} + +/// The NVCNT advance owed when booting a verified, non-rolled-back image. +/// +/// Returns the image counter when it is strictly above the stored NVCNT, else +/// `None`. A higher counter on a confirmed bank means a prior confirm was cut +/// after clearing pending but before the bump, so advancing closes that rollback +/// window. +fn advance_owed(security_counter: u32, nvcnt: u32) -> Option +{ + if security_counter > nvcnt + { + Some(security_counter) + } + else + { + None + } +} + +/// The pure boot decision. +/// +/// # Arguments +/// +/// - `running`: the physical bank the firmware currently runs from. +/// - `pending`: the persistent pending-confirm record. +/// - `nvcnt`: the stored anti-rollback counter. +/// - `health`: the running bank image's verified health. +/// +/// # Returns +/// +/// Exactly one of [`BootDecision::Boot`], [`BootDecision::Revert`], or +/// [`BootDecision::Wedge`]. +pub(crate) fn decide +( + running: BankId, + pending: PendingFlag, + nvcnt: u32, + health: ImageHealth, +) + -> BootDecision +{ + match pending + { + PendingFlag::None => decide_no_pending(nvcnt, health), + PendingFlag::Armed(target) if running == target => + { + decide_swap_applied(nvcnt, health) + } + PendingFlag::Armed(_) => decide_swap_never_applied(nvcnt, health), + } +} + +/// No pending update: the running bank is the confirmed bank. +fn decide_no_pending(nvcnt: u32, health: ImageHealth) -> BootDecision +{ + match health + { + ImageHealth::Rejected => BootDecision::Wedge(WedgeReason::NoBootableImage), + ImageHealth::Verified { security_counter } => + { + if security_counter < nvcnt + { + BootDecision::Wedge(WedgeReason::RolledBack) + } + else + { + BootDecision::Boot(BootPlan + { + clear_outcome: false, + clear_pending: false, + advance_nvcnt: advance_owed(security_counter, nvcnt), + }) + } + } + } +} + +/// The swap took effect (running == armed target): a confirm is owed on the new +/// bank. +fn decide_swap_applied(nvcnt: u32, health: ImageHealth) -> BootDecision +{ + match health + { + // A new bank that fails to verify or is a rollback is reverted. The revert + // never bumps the NVCNT, so the reverted-to old image is not read as a + // downgrade. + ImageHealth::Rejected => BootDecision::Revert, + ImageHealth::Verified { security_counter } => + { + if security_counter < nvcnt + { + BootDecision::Revert + } + else + { + // Confirm: clear the outcome and the pending record, then bump the + // NVCNT to this image's counter last. An equal bump is a no-op + // against the monotone store, so re-confirming the same image + // spends no burn budget. + BootDecision::Boot(BootPlan + { + clear_outcome: true, + clear_pending: true, + advance_nvcnt: Some(security_counter), + }) + } + } + } +} + +/// The swap never took effect (running != armed target): the old bank booted, or +/// an auto-revert already landed back on it. Clear the stale record and boot the +/// old bank. +fn decide_swap_never_applied(nvcnt: u32, health: ImageHealth) -> BootDecision +{ + match health + { + // The old bank should be the previously confirmed image, so a rejection + // here means both banks are unbootable. Wedge, do not arm any swap. + ImageHealth::Rejected => BootDecision::Wedge(WedgeReason::NoBootableImage), + ImageHealth::Verified { security_counter } => + { + if security_counter < nvcnt + { + BootDecision::Wedge(WedgeReason::RolledBack) + } + else + { + // Boot the old bank and clear the stale Armed record. Preserve the + // outcome so a prior auto-revert stays visible. Heal the NVCNT if + // it lags the confirmed image. + BootDecision::Boot(BootPlan + { + clear_outcome: false, + clear_pending: true, + advance_nvcnt: advance_owed(security_counter, nvcnt), + }) + } + } + } +} diff --git a/crates/boot-stage/src/entry.rs b/crates/boot-stage/src/entry.rs new file mode 100644 index 0000000..6a87d14 --- /dev/null +++ b/crates/boot-stage/src/entry.rs @@ -0,0 +1,110 @@ +//! The target reset entry and the secure-to-secure hand-off jump. +//! +//! Compiled only for the embedded target. This is the untestable silicon glue: +//! the reset vector wires the real flash driver, runs the boot flow, then either +//! hands off to the secure app, wedges, or (on an auto-revert) has already reset. +//! +//! # The hand-off is a plain secure-to-secure branch +//! +//! The boot stage and the secure app both run in the Secure state (TZEN=1 boots +//! secure). Handing off to the app is an ordinary branch, not a BXNS: BXNS is +//! defined only for a secure-to-non-secure transition (PM0264 sec 2.5, Table 27), +//! and the app performs the NS transition later. The Thumb bit of the reset vector +//! is kept, never cleared. +//! +//! Sequence, grounded on RM0456 sec 4 / PM0264 sec 2.1.3 / 2.4: +//! 1. Read the app initial MSP (vector word 0) and reset entry (word 1) from the +//! app vector table at 0x0C014000. +//! 2. Point `SCB->VTOR` (the secure VTOR, 0xE000ED08) at the app vector table, +//! then `DSB` + `ISB` so the write completes before the branch. +//! 3. Clear `MSPLIM_S` (a software hand-off does not reset it, PM0264 sec +//! 2.1.3.3), set `MSP_S` to the app SP, then branch to the app reset handler. +//! +//! The boot stage does not reprogram the SAU, the secure MPU, or the SECWM: they +//! are provisioned once and persist across this internal jump. + +use cortex_m_rt::entry; +// The halting panic handler. A boot stage must never unwind: every fault is a +// deliberate typed wedge, this only backstops an unreachable panic. +use panic_halt as _; + +use crate::glue::BootOutcome; +use crate::glue::run; +use crate::key; +use crate::real::real_flash; + +/// The secure app vector table base (pages 10-19 link origin, 0x0C014000). +const APP_VECTOR_TABLE: u32 = 0x0C01_4000; + +/// The secure SCB VTOR register address (`SCB->VTOR`, resolves to VTOR_S). +const SCB_VTOR: u32 = 0xE000_ED08; + +/// The immutable boot-stage reset entry. +#[entry] +fn boot() -> ! +{ + let mut flash = real_flash(); + let root = match key::product_root_key() + { + Ok(key) => key, + // A corrupt pinned key must never fall back to trusting an image. + Err(_) => wedge(), + }; + + match run(&mut flash, &root) + { + BootOutcome::HandOff(_) => jump_to_secure_app(), + // On silicon the auto-revert already armed the option load and reset the + // part, so this arm is unreachable there. If control ever returns, wedge. + BootOutcome::Reverted => wedge(), + BootOutcome::Wedge(_) => wedge(), + } +} + +/// Halts fail-closed. No image is booted and no security state is left. +fn wedge() -> ! +{ + loop + { + mcu_arch::wfi(); + } +} + +/// Hands off to the secure app: sets the secure VTOR, MSP, then branches. +/// +/// Diverges: the app reset handler runs next and never returns here. +#[expect +( + unsafe_code, + reason = "secure-to-secure hand-off needs the VTOR write plus the MSP/MSPLIM/bx sequence" +)] +fn jump_to_secure_app() -> ! +{ + // SAFETY: a one-time boot hand-off. The two reads take aligned u32 words from + // the app vector table at its architectural secure-flash base (volatile, so + // they are neither reordered nor elided). SCB->VTOR is written at its + // architectural address. MSPLIM_S is cleared and MSP_S is set to the app's own + // initial SP before an ordinary branch to the app reset handler (Thumb bit + // kept). No value crosses as a pointer into this stage's memory, and the + // branch does not return. The SAU / MPU / SECWM are untouched (provisioned and + // persistent). This is a secure-to-secure branch, never a BXNS. + unsafe + { + let table = APP_VECTOR_TABLE as *const u32; + let app_msp = core::ptr::read_volatile(table); + let app_reset = core::ptr::read_volatile(table.add(1)); + core::ptr::write_volatile(SCB_VTOR as *mut u32, APP_VECTOR_TABLE); + core::arch::asm! + ( + "dsb", + "isb", + "msr msplim, {zero}", + "msr msp, {msp}", + "bx {reset}", + zero = in(reg) 0u32, + msp = in(reg) app_msp, + reset = in(reg) app_reset, + options(noreturn), + ); + } +} diff --git a/crates/boot-stage/src/glue.rs b/crates/boot-stage/src/glue.rs new file mode 100644 index 0000000..7ee0155 --- /dev/null +++ b/crates/boot-stage/src/glue.rs @@ -0,0 +1,167 @@ +//! The boot flow: read the seam, decide, apply, then report the hand-off. +//! +//! [`run`] is generic over [`BootFlash`], so the same orchestration the silicon +//! runs is driven on the host over a state mock. It reads the persistent inputs, +//! assesses the running bank, calls the [`decide`], applies the ordered +//! bookkeeping, and returns a [`BootOutcome`] the target entry acts on (jump, +//! reset, or wedge). It performs no jump itself. + +use image_verify::RootKey; + +use fw_update::BankId; +use fw_update::FlashError; +use fw_update::PendingFlag; +use fw_update::UpdateOutcome; + +use crate::decision::BootDecision; +use crate::decision::BootPlan; +use crate::decision::WedgeReason; +use crate::decision::decide; +use crate::health; +use crate::health::ImageHealth; +use crate::secwm::secwm_ok; +use crate::seam::BootFlash; + +/// What the boot stage decided to do, for the target entry to carry out. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BootOutcome +{ + /// Hand off to the running bank's secure app. + HandOff(BankId), + /// The auto-revert was armed. On silicon the option load has reset the part, + /// so this is reached only on the host model or if the reset did not fire. + Reverted, + /// Halt fail-closed. + Wedge(WedgeReason), +} + +/// Runs the boot decision over the seam and applies its bookkeeping. +/// +/// # Arguments +/// +/// - `flash`: the hardware seam (real driver on silicon, state mock on host). +/// - `root_key`: the pinned product root public key. +/// +/// # Returns +/// +/// A [`BootOutcome`] the caller acts on. Any unreadable input or mis-provisioned +/// watermark wedges before an image is trusted. +pub(crate) fn run(flash: &mut F, root_key: &RootKey) -> BootOutcome +where + F: BootFlash, +{ + // 0. Partition sanity, then the SECWM readback wedge, before trusting any + // isolation. A mis-provisioned part must not run. + if flash.require_partition().is_err() + { + return BootOutcome::Wedge(WedgeReason::Unreadable); + } + match flash.read_secwm() + { + Ok(readback) => + { + if !secwm_ok(&readback) + { + return BootOutcome::Wedge(WedgeReason::SecwmMismatch); + } + } + Err(_) => return BootOutcome::Wedge(WedgeReason::Unreadable), + } + + // 1. Read the persistent boot state. Any read fault fails closed. + let running = match flash.running_bank() + { + Ok(bank) => bank, + Err(_) => return BootOutcome::Wedge(WedgeReason::Unreadable), + }; + let pending = match flash.pending_read() + { + Ok(flag) => flag, + Err(_) => return BootOutcome::Wedge(WedgeReason::Unreadable), + }; + let nvcnt = match flash.nvcnt_read() + { + Ok(value) => value, + Err(_) => return BootOutcome::Wedge(WedgeReason::Unreadable), + }; + + // 2. Assess the running bank's image from its four segments. The immutable + // borrows are scoped so they drop before the mutable apply below. + let health = assess_running(flash, root_key); + + // 3. Decide, then act. + match decide(running, pending, nvcnt, health) + { + BootDecision::Boot(plan) => + { + // A verified image boots even if a bookkeeping write faults: the + // image is authentic, and the confirm or self-heal retries on the + // next boot. This mirrors the updater, which keeps the new bank + // booting on a confirm-write fault rather than bricking it. + let _ = apply_plan(flash, &plan); + BootOutcome::HandOff(running) + } + BootDecision::Revert => + { + let _ = apply_revert(flash); + BootOutcome::Reverted + } + BootDecision::Wedge(reason) => BootOutcome::Wedge(reason), + } +} + +/// Reads the running bank's four segments and assesses their health. +fn assess_running(flash: &F, root_key: &RootKey) -> ImageHealth +where + F: BootFlash, +{ + let descriptor = flash.active_descriptor(); + let secure_band = flash.active_secure_band(); + let ns_band = flash.active_ns_band(); + health::assess(descriptor, secure_band, ns_band, root_key) +} + +/// Applies a boot plan's bookkeeping in the safety-critical order: clear the +/// outcome, clear the pending record, then bump the NVCNT last. +/// +/// # Errors +/// +/// The first [`FlashError`] a step returns. A caller on the Boot path proceeds to +/// hand off regardless, so a fault only defers the confirm to the next boot. +fn apply_plan(flash: &mut F, plan: &BootPlan) -> Result<(), FlashError> +where + F: BootFlash, +{ + if plan.clear_outcome + { + flash.update_outcome_clear()?; + } + if plan.clear_pending + { + flash.pending_write(PendingFlag::None)?; + } + // The NVCNT bump is last, past every revert decision. + if let Some(value) = plan.advance_nvcnt + { + flash.nvcnt_bump(value)?; + } + Ok(()) +} + +/// Records the auto-revert outcome, then arms SWAP_BANK back toward the old bank. +/// +/// The NVCNT is never touched here, so a reverted-to old image is never a +/// downgrade. Recording the outcome before arming the swap keeps the event +/// visible even if the reset fires immediately after the arm. +/// +/// # Errors +/// +/// The first [`FlashError`] a step returns. +fn apply_revert(flash: &mut F) -> Result<(), FlashError> +where + F: BootFlash, +{ + flash.update_outcome_write(UpdateOutcome::AutoReverted)?; + flash.revert_swap()?; + Ok(()) +} diff --git a/crates/boot-stage/src/health.rs b/crates/boot-stage/src/health.rs new file mode 100644 index 0000000..2a91f7a --- /dev/null +++ b/crates/boot-stage/src/health.rs @@ -0,0 +1,123 @@ +//! The four-segment health check on the running bank's image. +//! +//! The boot stage reads the bank it is about to boot as four flash segments (the +//! same descriptor-page contract the updater writes): the page-9 descriptor +//! holding the header at [0:24] and the signature at [24:88], the secure payload +//! band (pages 10-19, secure alias), and the non-secure payload band (pages +//! 20-31, non-secure alias). It carves those into the logical image +//! `header || secure_payload || ns_payload || signature` and verifies the ECDSA +//! P-256 signature against the pinned root key. +//! +//! # Fail closed +//! +//! Any anomaly (a short descriptor, a payload length that overruns the bands, a +//! rejected signature) yields [`ImageHealth::Rejected`]. Nothing about the image +//! is trusted before the signature verifies. The anti-rollback comparison against +//! the NVCNT is not done here: [`ImageHealth::Verified`] carries the signed +//! security counter, and the boot decision applies the rollback policy. + +use image_verify::HEADER_LEN; +use image_verify::RootKey; +use image_verify::SIG_LEN; +use image_verify::verify_image; + +/// The byte offset of `payload_len` (u32 little-endian) inside the signed header. +/// +/// Mirrors the image format (magic[0:4], format_version, algorithm, version +/// fields, security_counter, then payload_len at offset 18). The boot stage needs +/// the payload split before verify so it can cut the bands, and the length lives +/// in the header. Reading it from the not-yet-verified header is safe: the +/// signature binds the true length, so a lie yields a wrong digest or a length +/// mismatch and the image is rejected. +const OFF_PAYLOAD_LEN: usize = 18; + +/// The verified health of the running bank's image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ImageHealth +{ + /// The four-segment ECDSA verify rejected the image, or it was malformed. + Rejected, + /// The signature verified. Carries the signed anti-rollback security counter + /// for the decision to compare against the NVCNT. + Verified + { + /// The monotonic anti-rollback counter from the signed header. + security_counter: u32, + }, +} + +/// Assesses the running bank's image from its three read-back flash segments. +/// +/// # Arguments +/// +/// - `descriptor`: the page-9 bytes, header at [0:24] then signature at [24:88]. +/// - `secure_band`: the secure payload sub-band (pages 10-19, secure alias). +/// - `ns_band`: the non-secure payload sub-band (pages 20-31, non-secure alias). +/// - `root_key`: the pinned product root public key. +/// +/// # Returns +/// +/// [`ImageHealth::Verified`] with the signed security counter only after the +/// ECDSA signature passes, [`ImageHealth::Rejected`] on any anomaly. +pub(crate) fn assess +( + descriptor: &[u8], + secure_band: &[u8], + ns_band: &[u8], + root_key: &RootKey, +) + -> ImageHealth +{ + let header = match descriptor.get(..HEADER_LEN) + { + Some(bytes) => bytes, + None => return ImageHealth::Rejected, + }; + let sig = match descriptor.get(HEADER_LEN..HEADER_LEN + SIG_LEN) + { + Some(bytes) => bytes, + None => return ImageHealth::Rejected, + }; + let payload_len = match header.get(OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4) + { + Some(bytes) => match <[u8; 4]>::try_from(bytes) + { + Ok(array) => u32::from_le_bytes(array) as usize, + Err(_) => return ImageHealth::Rejected, + }, + None => return ImageHealth::Rejected, + }; + + // Cut the payload across the SECWM boundary exactly as the updater does: the + // first bytes fill the secure band, the remainder the non-secure band. An + // over-long payload_len overruns a band and is rejected by the bounds check. + let secure_take = core::cmp::min(payload_len, secure_band.len()); + let ns_take = match payload_len.checked_sub(secure_take) + { + Some(value) => value, + None => return ImageHealth::Rejected, + }; + let secure_seg = match secure_band.get(..secure_take) + { + Some(bytes) => bytes, + None => return ImageHealth::Rejected, + }; + let ns_seg = match ns_band.get(..ns_take) + { + Some(bytes) => bytes, + None => return ImageHealth::Rejected, + }; + + let segments: [&[u8]; 4] = [header, secure_seg, ns_seg, sig]; + match verify_image(&segments, root_key) + { + Ok(verified) => ImageHealth::Verified + { + security_counter: verified.security_counter(), + }, + Err(_) => ImageHealth::Rejected, + } +} + +#[cfg(test)] +pub(crate) const OFF_PAYLOAD_LEN_FOR_TEST: usize = OFF_PAYLOAD_LEN; diff --git a/crates/boot-stage/src/key.rs b/crates/boot-stage/src/key.rs new file mode 100644 index 0000000..e917ed0 --- /dev/null +++ b/crates/boot-stage/src/key.rs @@ -0,0 +1,43 @@ +//! The pinned product root public key. +//! +//! The boot stage pins one ECDSA P-256 root public key, the trust anchor every +//! firmware image is verified against. The key is committed as a 65-byte +//! uncompressed SEC1 point in `product_root_key.sec1`, pulled in with +//! `include_bytes!`. A re-ceremony overwrites that one file, with no code change. +//! +//! # The production trust anchor +//! +//! The committed bytes are the production ceremony key, the public half of the +//! ECCP256 keypair held in the YubiKey PIV slot 82. The private key lives in the +//! hardware token and never enters the repo. It differs from the all-`0x01` dev +//! key (`DEV_ROOT_KEY_TEST_ONLY`), so the linked-ELF grep gate that forbids the +//! dev key stays meaningful. +//! +//! # Fail-safe direction +//! +//! The sole build pins this production slot. There is no dev-key fallback and no +//! feature that swaps in a test key, so a build cannot silently trust the wrong +//! anchor. + +use image_verify::ROOT_KEY_LEN; +use image_verify::RootKey; +use image_verify::VerifyError; + +/// The pinned root public key, a 65-byte uncompressed SEC1 point. +/// +/// `include_bytes!` fixes the length at compile time: a file that is not exactly +/// `ROOT_KEY_LEN` bytes fails to build. +pub(crate) const PROD_ROOT_KEY_SEC1: &[u8; ROOT_KEY_LEN] = + include_bytes!("../product_root_key.sec1"); + +/// Builds the pinned product root key. +/// +/// # Errors +/// +/// [`VerifyError::BadRootKey`] if the committed bytes are not an uncompressed +/// SEC1 point on the P-256 curve. The boot stage treats that as a wedge: a build +/// with a corrupt pinned key must never fall back to trusting an image. +pub(crate) fn product_root_key() -> Result +{ + RootKey::from_bytes(*PROD_ROOT_KEY_SEC1) +} diff --git a/crates/boot-stage/src/main.rs b/crates/boot-stage/src/main.rs new file mode 100644 index 0000000..b51c27b --- /dev/null +++ b/crates/boot-stage/src/main.rs @@ -0,0 +1,55 @@ +//! Immutable first-stage boot code for the A/B update model. +//! +//! Runs from pages 2-8 of whichever bank the hardware boots (SECBOOTADD0 = +//! 0x0C004000, selected by SWAP_BANK). It checks the partition and the secure +//! watermarks, verifies the running bank's signed image against the pinned +//! product root key, confirms or reverts a pending A/B swap, then hands off to the +//! secure app at 0x0C014000. +//! +//! # Two builds +//! +//! - Target (`target_os = "none"`): a `no_std` / `no_main` cortex-m-rt binary. The +//! `entry` and `real` modules hold the untestable silicon glue (the real flash +//! driver port, the register reads, and the secure-to-secure hand-off jump). +//! - Host: an empty `main`, so the whole workspace stays host-checkable. The pure +//! decision, the health check, the SECWM decode, and the boot flow over a state +//! mock are all exercised by `cargo test`. +//! +//! # Anti-brick contract +//! +//! The boot decision is a pure function ([`decision::decide`]) over the persistent +//! state, proven exhaustively on the host across every post-cut state. The NVCNT +//! anti-rollback bump is done last and is mutually exclusive with a revert, so the +//! rollback floor can never rise above a bank that is then reverted away. Nothing +//! irreversible runs on silicon in this crate's host-proof build. + +#![cfg_attr(target_os = "none", no_std)] +#![cfg_attr(target_os = "none", no_main)] +// The host build with no test harness is an empty `main` that references none of +// the boot logic, so its pub(crate) items are unused there by design. dead_code +// stays a live warning in the test and target builds, where the logic is used. +#![cfg_attr(not(any(test, target_os = "none")), allow(dead_code))] + +mod decision; +mod glue; +mod health; +mod key; +mod seam; +mod secwm; + +// Silicon-only glue, compiled only for the embedded target. +#[cfg(target_os = "none")] +mod entry; +#[cfg(target_os = "none")] +mod real; + +#[cfg(test)] +mod mock; +#[cfg(test)] +mod tests; + +/// Host stub entry. The real reset vector is the target-only `entry` module. +#[cfg(not(target_os = "none"))] +fn main() +{ +} diff --git a/crates/boot-stage/src/mock.rs b/crates/boot-stage/src/mock.rs new file mode 100644 index 0000000..daca14a --- /dev/null +++ b/crates/boot-stage/src/mock.rs @@ -0,0 +1,364 @@ +//! Host state mock for the boot flow and the power-cut harness. +//! +//! [`MockBootFlash`] models the real persistent state the boot stage reads and +//! writes: the SWAP_BANK bit, the two physical banks' images (read through the +//! low alias for whichever bank runs), the single metadata copy (pending, NVCNT, +//! outcome), and the SECWM readback. It models a power cut at any persistent +//! mutation boundary by unwinding, so the harness can prove that a cut at every +//! step leaves a state the decision recovers from. +//! +//! # Fidelity notes +//! +//! - The swap is staged by an arm and applied only at [`MockBootFlash::apply_reset`], +//! mirroring RM0456 sec 7.5.8 (the option load takes effect at the next reset). +//! A reboot without `apply_reset` models a cut before the option load committed, +//! which is exactly the "swap never took effect" case. +//! - A mutation applies durably or not at all (the cut fires before the write), +//! modelling the state-machine ordering, not sub-quad-word flash atomicity. + +use fw_update::BankId; +use fw_update::FlashError; +use fw_update::PendingFlag; +use fw_update::UpdateOutcome; +use image_verify::HEADER_LEN; +use image_verify::ImageVersion; +use image_verify::SIG_LEN; +use image_verify::encode_header; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; +use sha2::Digest; +use sha2::Sha256; +use std::vec::Vec; + +use crate::secwm::SecwmReadback; +use crate::secwm::SecwmWindow; +use crate::seam::BootFlash; + +/// The phrase whose SHA-256 is the bring-up private scalar. The test signing key +/// (distinct from the pinned production key) is this scalar's public key. The test +/// fixtures inject it, so they are independent of the pinned trust anchor. +pub(crate) const BRINGUP_PHRASE: &[u8] = + b"patina_key MCU image root - BRING-UP ONLY - replace at ceremony freeze"; + +/// The modelled secure payload sub-band capacity (bytes). Small on the host: the +/// carving logic is size-agnostic, so a compact band still exercises the split. +pub(crate) const MOCK_SECURE_BAND_LEN: usize = 96; +/// The modelled non-secure payload sub-band capacity (bytes). +pub(crate) const MOCK_NS_BAND_LEN: usize = 96; + +/// The panic payload a modelled power cut unwinds with. +pub(crate) const POWER_CUT: &str = "BOOT_STAGE_POWER_CUT"; + +/// The bring-up test signing key (its public key is the test signing key, not the +/// pinned production key). +pub(crate) fn bringup_signing_key() -> SigningKey +{ + let scalar = Sha256::digest(BRINGUP_PHRASE); + SigningKey::from_slice(&scalar).expect("bring-up scalar is a valid P-256 key") +} + +/// One bank's image, as the three segments the boot stage reads. +#[derive(Clone)] +pub(crate) struct BankImage +{ + /// Page-9 descriptor: header at [0:24], signature at [24:88]. + pub(crate) descriptor: Vec, + /// Secure payload sub-band (padded to `MOCK_SECURE_BAND_LEN` with erased 0xFF). + pub(crate) secure_band: Vec, + /// Non-secure payload sub-band (padded to `MOCK_NS_BAND_LEN` with erased 0xFF). + pub(crate) ns_band: Vec, +} + +impl BankImage +{ + /// Mints a healthy image signed by the bring-up key with the given counter. + /// + /// The payload is `payload_len` bytes of a fixed pattern, de-interleaved into + /// the secure band first, then the non-secure band, exactly as the device + /// lays it out. A `payload_len` above `MOCK_SECURE_BAND_LEN` therefore spans + /// the SECWM boundary. + pub(crate) fn healthy(security_counter: u32, payload_len: usize) -> BankImage + { + assert!(payload_len <= MOCK_SECURE_BAND_LEN + MOCK_NS_BAND_LEN); + let version = ImageVersion + { + major: 1, + minor: 0, + revision: 0, + build: 0, + }; + let header = encode_header(version, security_counter, payload_len as u32); + + // A deterministic payload pattern. + let mut payload = Vec::with_capacity(payload_len); + for i in 0..payload_len + { + payload.push((i as u8) ^ 0x5A); + } + + let mut signed = Vec::new(); + signed.extend_from_slice(&header); + signed.extend_from_slice(&payload); + let sk = bringup_signing_key(); + let sig: p256::ecdsa::Signature = sk.sign(&signed); + let sig = sig.normalize_s(); + + let mut descriptor = Vec::with_capacity(HEADER_LEN + SIG_LEN); + descriptor.extend_from_slice(&header); + descriptor.extend_from_slice(&sig.to_bytes()); + + // De-interleave the payload across the two bands, padding with erased 0xFF. + let secure_take = core::cmp::min(payload_len, MOCK_SECURE_BAND_LEN); + let ns_take = payload_len - secure_take; + let mut secure_band = vec![0xFF; MOCK_SECURE_BAND_LEN]; + secure_band[..secure_take].copy_from_slice(&payload[..secure_take]); + let mut ns_band = vec![0xFF; MOCK_NS_BAND_LEN]; + ns_band[..ns_take].copy_from_slice(&payload[secure_take..]); + + BankImage + { + descriptor, + secure_band, + ns_band, + } + } + + /// Mints an unhealthy image: a healthy image with one signature byte flipped, + /// so the ECDSA verify rejects it. + pub(crate) fn unhealthy(security_counter: u32, payload_len: usize) -> BankImage + { + let mut image = BankImage::healthy(security_counter, payload_len); + // Flip a byte inside the signature (descriptor [24:88]). + image.descriptor[HEADER_LEN] ^= 0xFF; + image + } + + /// An all-erased bank (0xFF), which fails to verify (bad magic). + pub(crate) fn erased() -> BankImage + { + BankImage + { + descriptor: vec![0xFF; HEADER_LEN + SIG_LEN], + secure_band: vec![0xFF; MOCK_SECURE_BAND_LEN], + ns_band: vec![0xFF; MOCK_NS_BAND_LEN], + } + } +} + +/// The provisioned-correct SECWM readback (both banks pages 0..=19 secure). +pub(crate) fn good_secwm() -> SecwmReadback +{ + SecwmReadback + { + bank1: SecwmWindow { start: 0, end: 19 }, + bank2: SecwmWindow { start: 0, end: 19 }, + } +} + +/// The host state mock. +pub(crate) struct MockBootFlash +{ + /// OPTR.SWAP_BANK: false => Bank1 at the low alias (runs), true => Bank2. + pub(crate) swap: bool, + /// A staged swap, applied at the next [`Self::apply_reset`]. + pub(crate) staged_swap: Option, + /// Physical Bank 1 image. + pub(crate) bank1: BankImage, + /// Physical Bank 2 image. + pub(crate) bank2: BankImage, + /// The single pending-confirm record (survives a swap). + pub(crate) pending: PendingFlag, + /// The single NVCNT (survives a swap). + pub(crate) nvcnt: u32, + /// The single update-outcome record (survives a swap). + pub(crate) outcome: UpdateOutcome, + /// The SECWM readback the wedge checks. + pub(crate) secwm: SecwmReadback, + /// Whether the partition (DUALBANK / TZEN) reads sane. + pub(crate) partition_ok: bool, + + /// The count of persistent mutations that have durably applied this run. + pub(crate) mutations: usize, + /// If set, the mutation at this index unwinds (a modelled power cut) before it + /// applies. + pub(crate) cut_at: Option, +} + +impl MockBootFlash +{ + /// A confirmed steady state: no pending, both banks healthy, NVCNT matches. + pub(crate) fn confirmed + ( + swap: bool, + bank1: BankImage, + bank2: BankImage, + nvcnt: u32, + ) + -> MockBootFlash + { + MockBootFlash + { + swap, + staged_swap: None, + bank1, + bank2, + pending: PendingFlag::None, + nvcnt, + outcome: UpdateOutcome::None, + secwm: good_secwm(), + partition_ok: true, + mutations: 0, + cut_at: None, + } + } + + /// The physical bank that currently runs (sits at the low alias). + pub(crate) fn running(&self) -> BankId + { + if self.swap + { + BankId::Bank2 + } + else + { + BankId::Bank1 + } + } + + /// The image of the running bank. + fn running_image(&self) -> &BankImage + { + if self.swap + { + &self.bank2 + } + else + { + &self.bank1 + } + } + + /// Applies a staged swap, modelling the reset that the option load commits on. + pub(crate) fn apply_reset(&mut self) + { + if let Some(target) = self.staged_swap.take() + { + self.swap = target; + } + } + + /// Arms a modelled power cut at the given persistent-mutation index, and + /// resets the mutation counter for a fresh run. + pub(crate) fn arm_cut(&mut self, index: Option) + { + self.cut_at = index; + self.mutations = 0; + } + + /// Checkpoints a persistent mutation. Unwinds (a modelled power cut) if this + /// mutation is the armed cut index, before the mutation applies. + fn checkpoint(&mut self) + { + if self.cut_at == Some(self.mutations) + { + panic!("{}", POWER_CUT); + } + self.mutations += 1; + } +} + +impl BootFlash for MockBootFlash +{ + fn require_partition(&mut self) -> Result<(), FlashError> + { + if self.partition_ok + { + Ok(()) + } + else + { + Err(FlashError::Hardware) + } + } + + fn read_secwm(&mut self) -> Result + { + Ok(self.secwm) + } + + fn running_bank(&mut self) -> Result + { + Ok(self.running()) + } + + fn pending_read(&mut self) -> Result + { + Ok(self.pending) + } + + fn nvcnt_read(&mut self) -> Result + { + Ok(self.nvcnt) + } + + fn active_descriptor(&self) -> &[u8] + { + &self.running_image().descriptor + } + + fn active_secure_band(&self) -> &[u8] + { + &self.running_image().secure_band + } + + fn active_ns_band(&self) -> &[u8] + { + &self.running_image().ns_band + } + + fn update_outcome_clear(&mut self) -> Result<(), FlashError> + { + self.checkpoint(); + self.outcome = UpdateOutcome::None; + Ok(()) + } + + fn update_outcome_write + ( + &mut self, + outcome: UpdateOutcome, + ) + -> Result<(), FlashError> + { + self.checkpoint(); + self.outcome = outcome; + Ok(()) + } + + fn pending_write(&mut self, flag: PendingFlag) -> Result<(), FlashError> + { + self.checkpoint(); + self.pending = flag; + Ok(()) + } + + fn nvcnt_bump(&mut self, value: u32) -> Result<(), FlashError> + { + self.checkpoint(); + if value < self.nvcnt + { + return Err(FlashError::WriteFailed); + } + // Monotone store: an equal value is a no-op, a higher value advances. + self.nvcnt = value; + Ok(()) + } + + fn revert_swap(&mut self) -> Result<(), FlashError> + { + self.checkpoint(); + // Arm the swap toward the inactive bank. Applied at the next reset. + self.staged_swap = Some(!self.swap); + Ok(()) + } +} diff --git a/crates/boot-stage/src/real.rs b/crates/boot-stage/src/real.rs new file mode 100644 index 0000000..a40915d --- /dev/null +++ b/crates/boot-stage/src/real.rs @@ -0,0 +1,106 @@ +//! The real [`BootFlash`] backing, over the `mcu_flash` MMIO driver. +//! +//! Compiled only for the embedded target. The metadata, running-bank, and swap +//! methods delegate to the driver's `fw_update::FlashSeam` impl (the persistent +//! records and the revert). The image reads and the SECWM readback delegate to +//! the driver's running-bank accessors. So the boot stage drives the same +//! flash driver the updater does, with no second MMIO surface. + +use fw_update::BankId; +use fw_update::FlashError; +use fw_update::FlashSeam; +use fw_update::PendingFlag; +use fw_update::UpdateOutcome; +use mcu_flash::MmioFlash; +use mcu_flash::Stm32FlashSeam; + +use crate::secwm::SecwmReadback; +use crate::secwm::decode_window; +use crate::seam::BootFlash; + +/// The real flash seam: the MMIO driver over the volatile FLASH controller. +pub(crate) type RealFlash = Stm32FlashSeam; + +/// Builds the real flash seam. +pub(crate) fn real_flash() -> RealFlash +{ + Stm32FlashSeam::new(MmioFlash::new()) +} + +impl BootFlash for RealFlash +{ + fn require_partition(&mut self) -> Result<(), FlashError> + { + Stm32FlashSeam::require_partition(self) + } + + fn read_secwm(&mut self) -> Result + { + let (bank1, bank2) = self.read_secwm_raw()?; + Ok(SecwmReadback + { + bank1: decode_window(bank1), + bank2: decode_window(bank2), + }) + } + + fn running_bank(&mut self) -> Result + { + FlashSeam::running_bank(self) + } + + fn pending_read(&mut self) -> Result + { + FlashSeam::pending_read(self) + } + + fn nvcnt_read(&mut self) -> Result + { + FlashSeam::nvcnt_read(self) + } + + fn active_descriptor(&self) -> &[u8] + { + Stm32FlashSeam::active_descriptor(self) + } + + fn active_secure_band(&self) -> &[u8] + { + Stm32FlashSeam::active_secure_band(self) + } + + fn active_ns_band(&self) -> &[u8] + { + Stm32FlashSeam::active_ns_band(self) + } + + fn update_outcome_clear(&mut self) -> Result<(), FlashError> + { + FlashSeam::update_outcome_clear(self) + } + + fn update_outcome_write + ( + &mut self, + outcome: UpdateOutcome, + ) + -> Result<(), FlashError> + { + FlashSeam::update_outcome_write(self, outcome) + } + + fn pending_write(&mut self, flag: PendingFlag) -> Result<(), FlashError> + { + FlashSeam::pending_write(self, flag) + } + + fn nvcnt_bump(&mut self, value: u32) -> Result<(), FlashError> + { + FlashSeam::nvcnt_bump(self, value) + } + + fn revert_swap(&mut self) -> Result<(), FlashError> + { + FlashSeam::revert_swap(self) + } +} diff --git a/crates/boot-stage/src/seam.rs b/crates/boot-stage/src/seam.rs new file mode 100644 index 0000000..1bdf8fd --- /dev/null +++ b/crates/boot-stage/src/seam.rs @@ -0,0 +1,122 @@ +//! The hardware seam the boot stage drives. +//! +//! Every register read, image read, persistent write, and swap arm the boot stage +//! needs is a method on [`BootFlash`]. The [`crate::decision`] never touches +//! it. On silicon the real driver (`mcu_flash::Stm32FlashSeam`) backs it (see the +//! target-only `real` module). On the host a faithful state mock backs it, so the +//! whole boot flow is proven without hardware. +//! +//! The metadata and swap-control methods mirror `fw_update::FlashSeam` (the +//! updater's view) and reuse its `BankId` / `PendingFlag` / `UpdateOutcome` / +//! `FlashError` vocabulary, so both sides agree on the persistent encoding. The +//! image reads are the boot stage's own: they read the running (active) bank +//! through the low alias, whereas the updater reads the inactive bank through the +//! high alias. + +use fw_update::BankId; +use fw_update::FlashError; +use fw_update::PendingFlag; +use fw_update::UpdateOutcome; + +use crate::secwm::SecwmReadback; + +/// The only path from the boot stage to flash, the persistent records, and the +/// swap arm. +/// +/// Each method returns a typed [`Result`] so a fault fails closed. The image-read +/// methods borrow the memory-mapped running bank, so the bytes verified are the +/// bytes the hand-off boots. +pub(crate) trait BootFlash +{ + /// Asserts the partition is sane (DUALBANK and TZEN set). + /// + /// # Errors + /// + /// [`FlashError::Hardware`] if the part is not in the dual-bank secure + /// posture the layout requires. + fn require_partition(&mut self) -> Result<(), FlashError>; + + /// Reads the two flash secure-watermark registers back. + /// + /// # Errors + /// + /// [`FlashError::Hardware`] if the registers are unreadable. + fn read_secwm(&mut self) -> Result; + + /// Reports the bank the firmware currently runs from (the low-alias bank). + /// + /// # Errors + /// + /// [`FlashError::Hardware`] if the bank-select state is unreadable. + fn running_bank(&mut self) -> Result; + + /// Reads the persistent pending-confirm record. + /// + /// # Errors + /// + /// [`FlashError::Hardware`] if the record store is unreadable. + fn pending_read(&mut self) -> Result; + + /// Reads the monotone flash anti-rollback counter (NVCNT). + /// + /// # Errors + /// + /// [`FlashError::Hardware`] if the counter store is unreadable. + fn nvcnt_read(&mut self) -> Result; + + /// Borrows the running bank's image descriptor (page 9, secure alias): the + /// header at [0:24] and the signature at [24:88]. + fn active_descriptor(&self) -> &[u8]; + + /// Borrows the running bank's secure payload sub-band (pages 10-19, secure + /// alias). + fn active_secure_band(&self) -> &[u8]; + + /// Borrows the running bank's non-secure payload sub-band (pages 20-31, + /// non-secure alias). + fn active_ns_band(&self) -> &[u8]; + + /// Clears the update-outcome record back to [`UpdateOutcome::None`]. + /// + /// # Errors + /// + /// [`FlashError::WriteFailed`] on a write fault. + fn update_outcome_clear(&mut self) -> Result<(), FlashError>; + + /// Writes the update-outcome record. + /// + /// # Errors + /// + /// [`FlashError::WriteFailed`] on a write fault. + fn update_outcome_write + ( + &mut self, + outcome: UpdateOutcome, + ) + -> Result<(), FlashError>; + + /// Writes the persistent pending-confirm record. + /// + /// # Errors + /// + /// [`FlashError::WriteFailed`] on a write fault. + fn pending_write(&mut self, flag: PendingFlag) -> Result<(), FlashError>; + + /// Bumps the monotone NVCNT to `value` (an equal value is a no-op). + /// + /// # Errors + /// + /// [`FlashError::CounterExhausted`] if the burn budget is spent, + /// [`FlashError::WriteFailed`] if `value` is below the stored counter. + fn nvcnt_bump(&mut self, value: u32) -> Result<(), FlashError>; + + /// Arms SWAP_BANK back toward the inactive (old) bank, the auto-revert. + /// + /// On real silicon this triggers the option load and resets the part, which + /// applies the swap. + /// + /// # Errors + /// + /// [`FlashError::Hardware`] on a controller fault. + fn revert_swap(&mut self) -> Result<(), FlashError>; +} diff --git a/crates/boot-stage/src/secwm.rs b/crates/boot-stage/src/secwm.rs new file mode 100644 index 0000000..aa01d71 --- /dev/null +++ b/crates/boot-stage/src/secwm.rs @@ -0,0 +1,84 @@ +//! The SECWM readback wedge. +//! +//! Before the boot stage trusts the SAU / MPU / SECWM isolation, it reads the two +//! flash secure-watermark registers back and refuses to run a mis-provisioned +//! part. Under the widened SAU (the secure image is protected only by SECWM), a +//! part whose watermarks do not cover pages 0..=19 secure must not boot: a +//! non-secure fetch could reach secure code, or a secure page could sit +//! unprotected. +//! +//! # Registers +//! +//! `FLASH_SECWM1R1` (physical Bank 1, secure address 0x5002_2050) and +//! `FLASH_SECWM2R1` (Bank 2, 0x5002_2060). In each word `PSTRT` is bits [7:0] and +//! `PEND` is bits [23:16], but on the STM32U535/545 only the low 5 bits of each +//! field are page-index bits (32 pages per bank), so the decode masks to 5 bits. +//! Both registers are secure-read-only: a non-secure read is RAZ, so a TZEN=0 +//! part reads all zeros here, which fails this check as well. RM0456 sec 7.9.17 / +//! 7.9.21, Table 59 (inclusive page bounds). + +/// The expected first secure page (inclusive). Pages 0..=`EXPECTED_PEND` secure. +pub(crate) const EXPECTED_PSTRT: u8 = 0; + +/// The expected last secure page (inclusive). Matches `mcu_flash` SECWM_PEND: the +/// image band splits at page 19 (pages 0..=19 secure, 20..=31 non-secure). +pub(crate) const EXPECTED_PEND: u8 = 19; + +/// The 5-bit page-index mask for the STM32U535/545 SECWM PSTRT/PEND fields +/// (32 pages per bank). Part-specific: RM0456 sec 7.9.17 limits SECWM1_PSTRT/PEND +/// to 5 bits on the STM32U535/545. On the STM32U575/585 the same fields are +/// 7 bits, where masking to 0x1F would drop the upper page bits and turn this +/// wedge fail-open (a page index above 31 would decode as an in-range page). A +/// 7-bit part needs 0x7F here plus a re-review of this wedge against the wider +/// watermark window. +const PAGE_FIELD_MASK: u32 = 0x1F; + +/// The PEND field shift inside a `FLASH_SECWMxR1` word (bits [23:16]). +const PEND_SHIFT: u32 = 16; + +/// One bank's read-back secure watermark, as inclusive page bounds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SecwmWindow +{ + /// The first secure page (inclusive). + pub(crate) start: u8, + /// The last secure page (inclusive). + pub(crate) end: u8, +} + +/// Both banks' read-back watermarks. The boot stage requires both to match the +/// provisioned layout, so a swap can never expose an unprotected bank. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SecwmReadback +{ + /// Physical Bank 1 watermark (`FLASH_SECWM1R1`). + pub(crate) bank1: SecwmWindow, + /// Physical Bank 2 watermark (`FLASH_SECWM2R1`). + pub(crate) bank2: SecwmWindow, +} + +/// Decodes a raw `FLASH_SECWMxR1` word into inclusive page bounds. +/// +/// `PSTRT` is bits [4:0], `PEND` is bits [20:16] on the U535/545 (masked to the +/// 5 usable page-index bits, RM0456 sec 7.9.17 / 7.9.21). +pub(crate) fn decode_window(word: u32) -> SecwmWindow +{ + let start = (word & PAGE_FIELD_MASK) as u8; + let end = ((word >> PEND_SHIFT) & PAGE_FIELD_MASK) as u8; + SecwmWindow { start, end } +} + +/// Reports whether both watermarks exactly match the provisioned layout. +/// +/// Fails closed: any deviation (a factory-default part, a swap mix-up, a TZEN=0 +/// RAZ read) yields a mismatch and the boot stage wedges. +pub(crate) fn secwm_ok(readback: &SecwmReadback) -> bool +{ + window_ok(&readback.bank1) && window_ok(&readback.bank2) +} + +/// Reports whether one watermark matches the expected inclusive bounds. +fn window_ok(window: &SecwmWindow) -> bool +{ + window.start == EXPECTED_PSTRT && window.end == EXPECTED_PEND +} diff --git a/crates/boot-stage/src/tests.rs b/crates/boot-stage/src/tests.rs new file mode 100644 index 0000000..a554288 --- /dev/null +++ b/crates/boot-stage/src/tests.rs @@ -0,0 +1,746 @@ +//! Host proof of the boot stage. +//! +//! Four layers: the decision state machine (exhaustive over the input +//! space), the four-segment health check, the SECWM wedge, and the whole boot +//! flow over the state mock including a power-cut census that cuts at every +//! persistent-mutation boundary and proves recovery. + +use std::panic::AssertUnwindSafe; +use std::string::String; + +use fw_update::BankId; +use fw_update::PendingFlag; +use fw_update::UpdateOutcome; +use image_verify::HEADER_LEN; +use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; +use image_verify::RootKey; +use image_verify::SIG_LEN; +use image_verify::encode_header; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; + +use crate::decision::BootDecision; +use crate::decision::BootPlan; +use crate::decision::WedgeReason; +use crate::decision::decide; +use crate::glue::BootOutcome; +use crate::glue::run; +use crate::health; +use crate::health::ImageHealth; +use crate::key; +use crate::mock::BankImage; +use crate::mock::MockBootFlash; +use crate::mock::POWER_CUT; +use crate::mock::bringup_signing_key; +use crate::mock::good_secwm; +use crate::secwm::SecwmReadback; +use crate::secwm::SecwmWindow; +use crate::secwm::decode_window; +use crate::secwm::secwm_ok; + +/// The public key the fixtures sign with: the bring-up TEST key. +/// +/// The boot flow verifies whichever root key is injected into `run` / `assess`, so +/// the tests inject the bring-up key that `mock.rs` signs its fixtures with. This +/// is deliberately independent of the pinned production trust anchor (the slot-82 +/// key), which the golden test pins separately. The private half of the production +/// key lives in the YubiKey and cannot sign host fixtures. +fn bringup_root() -> RootKey +{ + let point = bringup_signing_key().verifying_key().to_sec1_point(false); + let bytes: [u8; ROOT_KEY_LEN] = point + .as_ref() + .try_into() + .expect("uncompressed SEC1 point is 65 bytes"); + RootKey::from_bytes(bytes).expect("bring-up public key is on-curve") +} + +#[test] +fn pinned_key_is_a_valid_point() +{ + assert!(key::product_root_key().is_ok()); +} + +#[test] +fn pinned_key_is_production_key() +{ + // The golden pin: the exact production trust anchor. + // This literal is the durable pin: an accidental change + // to product_root_key.sec1 fails here. + const PRODUCTION_ROOT_KEY: [u8; 65] = [ + 0x04, 0xdd, 0x0a, 0x85, 0xa4, 0x3d, 0x1f, 0x56, + 0xa9, 0x72, 0x53, 0xd3, 0xd4, 0xe0, 0xf3, 0xcd, + 0x22, 0x9e, 0xcb, 0x6b, 0xdf, 0x0b, 0x63, 0x82, + 0x02, 0x90, 0x5e, 0x0d, 0xa9, 0x06, 0xde, 0x5d, + 0xe8, 0x48, 0xaf, 0x17, 0x4f, 0x37, 0x90, 0xbc, + 0xcb, 0x9b, 0x57, 0xa2, 0x59, 0x80, 0x7a, 0x09, + 0x5f, 0x83, 0xab, 0x34, 0x84, 0xdd, 0x31, 0x88, + 0x96, 0x0f, 0x4c, 0xc3, 0xc9, 0x4d, 0x33, 0xf2, + 0xb8, + ]; + assert_eq!(key::PROD_ROOT_KEY_SEC1.as_slice(), PRODUCTION_ROOT_KEY.as_slice()); + // The committed bytes are a valid on-curve P-256 point. + assert!(key::product_root_key().is_ok()); +} + +#[test] +fn pinned_key_is_not_the_dev_key() +{ + // The all-0x01 dev public key (DEV_ROOT_KEY_TEST_ONLY / the image-verify fuzz + // key), the pinned production key must differ. + const DEV_PUBKEY: [u8; 65] = [ + 0x04, 0x6f, 0xf0, 0x3b, 0x94, 0x92, 0x41, 0xce, + 0x1d, 0xad, 0xd4, 0x35, 0x19, 0xe6, 0x96, 0x0e, + 0x0a, 0x85, 0xb4, 0x1a, 0x69, 0xa0, 0x5c, 0x32, + 0x81, 0x03, 0xaa, 0x2b, 0xce, 0x15, 0x94, 0xca, + 0x16, 0x3c, 0x4f, 0x75, 0x3a, 0x55, 0xbf, 0x01, + 0xdc, 0x53, 0xf6, 0xc0, 0xb0, 0xc7, 0xee, 0xe7, + 0x8b, 0x40, 0xc6, 0xff, 0x7d, 0x25, 0xa9, 0x6e, + 0x22, 0x82, 0xb9, 0x89, 0xce, 0xf7, 0x1c, 0x14, + 0x4a, + ]; + assert_ne!(key::PROD_ROOT_KEY_SEC1.as_slice(), DEV_PUBKEY.as_slice()); +} + +#[test] +fn healthy_image_verifies_and_carries_the_counter() +{ + let image = BankImage::healthy(7, 20); + let health = health::assess + ( + &image.descriptor, + &image.secure_band, + &image.ns_band, + &bringup_root(), + ); + assert_eq!(health, ImageHealth::Verified { security_counter: 7 }); +} + +#[test] +fn payload_spanning_the_secwm_boundary_verifies() +{ + // A payload longer than the secure band spills into the non-secure band, so + // the carving in `assess` must split it exactly like the device layout. + let image = BankImage::healthy(3, 150); + let health = health::assess + ( + &image.descriptor, + &image.secure_band, + &image.ns_band, + &bringup_root(), + ); + assert_eq!(health, ImageHealth::Verified { security_counter: 3 }); +} + +#[test] +fn tampered_payload_is_rejected() +{ + let mut image = BankImage::healthy(7, 20); + image.secure_band[0] ^= 0x01; + let health = health::assess + ( + &image.descriptor, + &image.secure_band, + &image.ns_band, + &bringup_root(), + ); + assert_eq!(health, ImageHealth::Rejected); +} + +#[test] +fn bad_signature_is_rejected() +{ + let image = BankImage::unhealthy(7, 20); + let health = health::assess + ( + &image.descriptor, + &image.secure_band, + &image.ns_band, + &bringup_root(), + ); + assert_eq!(health, ImageHealth::Rejected); +} + +#[test] +fn erased_bank_is_rejected() +{ + let image = BankImage::erased(); + let health = health::assess( + &image.descriptor, + &image.secure_band, + &image.ns_band, + &bringup_root(), + ); + assert_eq!(health, ImageHealth::Rejected); +} + +#[test] +fn image_signed_by_a_foreign_key_is_rejected() +{ + // Sign a well-formed image with a different scalar than the pinned key. + let payload = [0xABu8; 20]; + let header = encode_header( + ImageVersion { major: 1, minor: 0, revision: 0, build: 0 }, + 4, + payload.len() as u32, + ); + let mut signed = std::vec::Vec::new(); + signed.extend_from_slice(&header); + signed.extend_from_slice(&payload); + let foreign = SigningKey::from_slice(&[7u8; 32]).unwrap(); + let sig: p256::ecdsa::Signature = foreign.sign(&signed); + let sig = sig.normalize_s(); + let mut descriptor = std::vec::Vec::new(); + descriptor.extend_from_slice(&header); + descriptor.extend_from_slice(&sig.to_bytes()); + let mut secure_band = std::vec![0xFFu8; 96]; + secure_band[..20].copy_from_slice(&payload); + let ns_band = std::vec![0xFFu8; 96]; + + let health = + health::assess(&descriptor, &secure_band, &ns_band, &bringup_root()); + assert_eq!(health, ImageHealth::Rejected); +} + +#[test] +fn short_descriptor_is_rejected() +{ + let image = BankImage::healthy(7, 20); + let short = &image.descriptor[..HEADER_LEN + SIG_LEN - 4]; + let health = + health::assess(short, &image.secure_band, &image.ns_band, &bringup_root()); + assert_eq!(health, ImageHealth::Rejected); +} + +#[test] +fn payload_len_overrunning_the_bands_is_rejected() +{ + // Rewrite the header's payload_len to a value larger than the two bands hold. + // The carve bounds-check rejects it before any curve work. + let mut image = BankImage::healthy(7, 20); + let huge = 100_000u32.to_le_bytes(); + image.descriptor[18..22].copy_from_slice(&huge); + let health = health::assess( + &image.descriptor, + &image.secure_band, + &image.ns_band, + &bringup_root(), + ); + assert_eq!(health, ImageHealth::Rejected); +} + +#[test] +fn payload_len_offset_matches_the_encoder() +{ + // Guard the hardcoded OFF_PAYLOAD_LEN against the image-verify encoder. + let header = encode_header( + ImageVersion { major: 1, minor: 0, revision: 0, build: 0 }, + 0, + 0xDEAD_BEEF, + ); + let at = health::OFF_PAYLOAD_LEN_FOR_TEST; + assert_eq!(&header[at..at + 4], &0xDEAD_BEEFu32.to_le_bytes()); +} + +#[test] +fn secwm_decode_masks_to_five_bits() +{ + // PSTRT=0, PEND=19 encodes as (19 << 16). + assert_eq!(decode_window(0x0013_0000), SecwmWindow { start: 0, end: 19 }); + // The upper reserved bits of each field are ignored (masked to 5 bits). + assert_eq!(decode_window(0x00FF_00E0), SecwmWindow { start: 0, end: 31 }); +} + +#[test] +fn factory_default_secwm_fails_closed() +{ + // The unprogrammed U535/545 value 0xFFFF_FF80 decodes to pages 0..=31, which + // is not the provisioned 0..=19, so the wedge fires. + let window = decode_window(0xFFFF_FF80); + assert_eq!(window, SecwmWindow { start: 0, end: 31 }); + let readback = SecwmReadback { bank1: window, bank2: window }; + assert!(!secwm_ok(&readback)); +} + +#[test] +fn provisioned_secwm_passes() +{ + assert!(secwm_ok(&good_secwm())); +} + +#[test] +fn one_mis_provisioned_bank_fails_closed() +{ + let readback = SecwmReadback + { + bank1: SecwmWindow { start: 0, end: 19 }, + bank2: SecwmWindow { start: 0, end: 18 }, + }; + assert!(!secwm_ok(&readback)); +} + +#[test] +fn secwm_wedge_fires_in_the_boot_flow() +{ + let mut mock = MockBootFlash::confirmed( + false, + BankImage::healthy(3, 20), + BankImage::healthy(3, 20), + 3, + ); + mock.secwm = SecwmReadback + { + bank1: SecwmWindow { start: 0, end: 31 }, + bank2: SecwmWindow { start: 0, end: 31 }, + }; + assert_eq!(run(&mut mock, &bringup_root()), + BootOutcome::Wedge(WedgeReason::SecwmMismatch)); +} + +#[test] +fn bad_partition_wedges_before_trusting_isolation() +{ + let mut mock = MockBootFlash::confirmed( + false, + BankImage::healthy(3, 20), + BankImage::healthy(3, 20), + 3, + ); + mock.partition_ok = false; + assert_eq!(run(&mut mock, &bringup_root()), + BootOutcome::Wedge(WedgeReason::Unreadable)); +} + +fn health_cases() -> [ImageHealth; 4] +{ + [ + ImageHealth::Rejected, + ImageHealth::Verified { security_counter: 0 }, + ImageHealth::Verified { security_counter: 5 }, + ImageHealth::Verified { security_counter: 10 }, + ] +} + +#[test] +fn decision_never_reverts_and_bumps_together() +{ + // The load-bearing anti-brick property: over the whole input space, a single + // decision is either a Revert (never a bump) or a Boot whose bump, when + // present, matches a Verified, non-rolled-back image. A Revert never carries a + // plan, so the NVCNT can never rise on the same decision that reverts. + for running in [BankId::Bank1, BankId::Bank2] + { + for pending in [ + PendingFlag::None, + PendingFlag::Armed(BankId::Bank1), + PendingFlag::Armed(BankId::Bank2), + ] + { + for nvcnt in [0u32, 5, 10] + { + for health in health_cases() + { + let decision = decide(running, pending, nvcnt, health); + match decision + { + BootDecision::Revert => + { + // A revert only ever follows the swap-applied case with + // an unhealthy or rolled-back new image. + let applied = matches!(pending, PendingFlag::Armed(t) if t == running); + assert!(applied, "revert only when the swap applied"); + let bad = match health + { + ImageHealth::Rejected => true, + ImageHealth::Verified { security_counter } => + { + security_counter < nvcnt + } + }; + assert!(bad, "revert only on a bad or rolled-back image"); + } + BootDecision::Boot(plan) => + { + if let Some(v) = plan.advance_nvcnt + { + // A bump only ever advances toward a Verified, + // non-rolled-back counter. + match health + { + ImageHealth::Verified { security_counter } => + { + assert_eq!(v, security_counter); + assert!(security_counter >= nvcnt); + } + ImageHealth::Rejected => + { + panic!("bumped on a rejected image"); + } + } + } + } + BootDecision::Wedge(_) => {} + } + } + } + } + } +} + +#[test] +fn normal_boot_of_a_confirmed_healthy_bank() +{ + let d = decide( + BankId::Bank1, + PendingFlag::None, + 5, + ImageHealth::Verified { security_counter: 5 }, + ); + assert_eq!(d, BootDecision::Boot(BootPlan + { + clear_outcome: false, + clear_pending: false, + advance_nvcnt: None, + })); +} + +#[test] +fn normal_boot_self_heals_a_lagging_nvcnt() +{ + // pending None, running verified sc=8, nvcnt=5: a prior confirm was cut after + // clearing pending but before the bump. The boot advances the NVCNT. + let d = decide( + BankId::Bank1, + PendingFlag::None, + 5, + ImageHealth::Verified { security_counter: 8 }, + ); + assert_eq!(d, BootDecision::Boot(BootPlan + { + clear_outcome: false, + clear_pending: false, + advance_nvcnt: Some(8), + })); +} + +#[test] +fn normal_boot_of_a_rejected_bank_wedges() +{ + let d = decide(BankId::Bank1, PendingFlag::None, 5, ImageHealth::Rejected); + assert_eq!(d, BootDecision::Wedge(WedgeReason::NoBootableImage)); +} + +#[test] +fn normal_boot_of_a_rolled_back_bank_wedges() +{ + let d = decide( + BankId::Bank1, + PendingFlag::None, + 9, + ImageHealth::Verified { security_counter: 3 }, + ); + assert_eq!(d, BootDecision::Wedge(WedgeReason::RolledBack)); +} + +#[test] +fn confirm_of_a_healthy_new_bank() +{ + // Armed(Bank2), running Bank2, healthy sc=5 >= nvcnt=3: confirm. + let d = decide( + BankId::Bank2, + PendingFlag::Armed(BankId::Bank2), + 3, + ImageHealth::Verified { security_counter: 5 }, + ); + assert_eq!(d, BootDecision::Boot(BootPlan + { + clear_outcome: true, + clear_pending: true, + advance_nvcnt: Some(5), + })); +} + +#[test] +fn unhealthy_new_bank_reverts() +{ + let d = decide( + BankId::Bank2, + PendingFlag::Armed(BankId::Bank2), + 3, + ImageHealth::Rejected, + ); + assert_eq!(d, BootDecision::Revert); +} + +#[test] +fn rolled_back_new_bank_reverts() +{ + let d = decide( + BankId::Bank2, + PendingFlag::Armed(BankId::Bank2), + 9, + ImageHealth::Verified { security_counter: 3 }, + ); + assert_eq!(d, BootDecision::Revert); +} + +#[test] +fn swap_never_applied_clears_the_stale_record_and_boots_old() +{ + // Armed(Bank2) but running Bank1: the swap never took effect. Clear the record + // and boot the old bank, preserving the outcome. + let d = decide( + BankId::Bank1, + PendingFlag::Armed(BankId::Bank2), + 3, + ImageHealth::Verified { security_counter: 3 }, + ); + assert_eq!(d, BootDecision::Boot(BootPlan + { + clear_outcome: false, + clear_pending: true, + advance_nvcnt: None, + })); +} + +#[test] +fn swap_never_applied_with_a_dead_old_bank_wedges() +{ + let d = decide( + BankId::Bank1, + PendingFlag::Armed(BankId::Bank2), + 3, + ImageHealth::Rejected, + ); + assert_eq!(d, BootDecision::Wedge(WedgeReason::NoBootableImage)); +} + +/// Runs one boot pass over the mock. +fn boot_once(mock: &mut MockBootFlash) -> BootOutcome +{ + run(mock, &bringup_root()) +} + +/// Drives to a stable outcome, applying the reset after each revert (bounded). +fn drive_to_stable(mock: &mut MockBootFlash) -> BootOutcome +{ + for _ in 0..8 + { + match boot_once(mock) + { + BootOutcome::Reverted => mock.apply_reset(), + other => return other, + } + } + panic!("boot did not stabilise"); +} + +/// Runs one boot pass with a modelled power cut armed at `index`, and asserts the +/// run unwound at exactly that boundary (the cut fired). +fn expect_cut_at(mock: &mut MockBootFlash, index: usize) +{ + mock.arm_cut(Some(index)); + let prev = std::panic::take_hook(); + std::panic::set_hook(std::boxed::Box::new(|_| {})); + let caught = std::panic::catch_unwind(AssertUnwindSafe(|| { + let _ = boot_once(mock); + })); + std::panic::set_hook(prev); + let payload = caught.expect_err("expected a power cut but the run completed"); + let message = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert_eq!(message, POWER_CUT, "unwound for a reason other than the cut"); + // Disarm for the recovery run. + mock.arm_cut(None); +} + +fn confirm_scenario() -> MockBootFlash +{ + // Running = Bank2 (swap true), a pending confirm toward Bank2, the new bank + // healthy with counter 5, the NVCNT still at the old 3. + let old = BankImage::healthy(3, 20); + let new = BankImage::healthy(5, 20); + let mut mock = MockBootFlash::confirmed(true, old, new, 3); + mock.pending = PendingFlag::Armed(BankId::Bank2); + mock +} + +fn revert_scenario() -> MockBootFlash +{ + // Running = Bank2 (swap true), a pending confirm toward Bank2, but the new + // bank is unhealthy. The old Bank1 is healthy at counter 3. + let old = BankImage::healthy(3, 20); + let new = BankImage::unhealthy(5, 20); + let mut mock = MockBootFlash::confirmed(true, old, new, 3); + mock.pending = PendingFlag::Armed(BankId::Bank2); + mock +} + +/// Counts the persistent mutations a clean run performs (the census length). +fn mutation_count(mut mock: MockBootFlash) -> usize +{ + let _ = drive_to_stable(&mut mock); + mock.mutations +} + +#[test] +fn confirm_flow_reaches_a_confirmed_state() +{ + let mut mock = confirm_scenario(); + assert_eq!(boot_once(&mut mock), BootOutcome::HandOff(BankId::Bank2)); + assert_eq!(mock.pending, PendingFlag::None); + assert_eq!(mock.nvcnt, 5); + assert_eq!(mock.outcome, UpdateOutcome::None); + assert_eq!(mock.running(), BankId::Bank2); +} + +#[test] +fn confirm_bumps_the_nvcnt_last() +{ + // Cut at the last mutation index: everything but the bump is applied, and the + // NVCNT is still the old value. This pins the bump as the terminal step. + let mut mock = confirm_scenario(); + let count = mutation_count(confirm_scenario()); + assert_eq!(count, 3, "confirm applies outcome-clear, pending-clear, bump"); + expect_cut_at(&mut mock, count - 1); + assert_eq!(mock.nvcnt, 3, "the bump is last, so it did not run before the cut"); + assert_eq!(mock.pending, PendingFlag::None, "pending was cleared before the bump"); +} + +#[test] +fn confirm_recovers_from_a_cut_at_every_boundary() +{ + let count = mutation_count(confirm_scenario()); + for index in 0..count + { + let mut mock = confirm_scenario(); + expect_cut_at(&mut mock, index); + // Reboot and run to a stable outcome with no further cut. + let outcome = drive_to_stable(&mut mock); + assert_eq!(outcome, BootOutcome::HandOff(BankId::Bank2), + "cut at {index} must still confirm the new bank"); + assert_eq!(mock.pending, PendingFlag::None, "cut at {index}"); + assert_eq!(mock.nvcnt, 5, "cut at {index}: the NVCNT reaches the new counter"); + assert_eq!(mock.running(), BankId::Bank2, "cut at {index}"); + } +} + +#[test] +fn revert_flow_returns_to_the_old_bank_without_bumping() +{ + let mut mock = revert_scenario(); + let outcome = drive_to_stable(&mut mock); + assert_eq!(outcome, BootOutcome::HandOff(BankId::Bank1)); + assert_eq!(mock.running(), BankId::Bank1, "the old bank boots"); + assert_eq!(mock.nvcnt, 3, "a revert never bumps the NVCNT"); + assert_eq!(mock.outcome, UpdateOutcome::AutoReverted, "the revert is surfaced"); + assert_eq!(mock.pending, PendingFlag::None, "the stale record is cleared"); +} + +/// Drives across resets with a global cut armed, catching the unwind whenever it +/// fires. The cut index counts persistent mutations across the whole flow, +/// including those after a reset (RM0456 sec 7.5.8), so a cut in the post-reset +/// recovery step is reached. Returns whether the cut fired. +fn drive_catching_global_cut(mock: &mut MockBootFlash) -> bool +{ + for _ in 0..8 + { + let prev = std::panic::take_hook(); + std::panic::set_hook(std::boxed::Box::new(|_| {})); + let result = std::panic::catch_unwind(AssertUnwindSafe(|| boot_once(mock))); + std::panic::set_hook(prev); + match result + { + Err(_) => return true, + Ok(BootOutcome::Reverted) => mock.apply_reset(), + Ok(_) => return false, + } + } + false +} + +#[test] +fn revert_recovers_from_a_cut_at_every_boundary_and_never_confirms() +{ + // The revert path spans a reset: the outcome write and the swap arm run before + // the reset, the stale-record clear runs after it. The census cut index is + // global across that reset boundary, streaming a genuinely rejected image + // through the full flow and asserting it never reaches a confirmed swap. + let count = mutation_count(revert_scenario()); + assert!(count >= 2, "revert writes the outcome then arms the swap"); + for index in 0..count + { + let mut mock = revert_scenario(); + mock.cut_at = Some(index); + mock.mutations = 0; + let fired = drive_catching_global_cut(&mut mock); + assert!(fired, "the global cut at {index} must fire"); + // Disarm and recover. + mock.cut_at = None; + let outcome = drive_to_stable(&mut mock); + assert_eq!(outcome, BootOutcome::HandOff(BankId::Bank1), + "cut at {index} must still land on the old bank"); + assert_eq!(mock.running(), BankId::Bank1, "cut at {index}"); + // The safety invariant for a rejected image: the NVCNT is never bumped and + // the unhealthy new bank is never confirmed. + assert_eq!(mock.nvcnt, 3, "cut at {index}: no bump on the revert path"); + assert_eq!(mock.pending, PendingFlag::None, "cut at {index}"); + } +} + +#[test] +fn revert_survives_an_interrupted_reset() +{ + // Model a cut during the option load: the swap is armed but the reset does not + // apply it (reboot without apply_reset). The new bank still runs, unhealthy, + // so the boot re-arms the revert. A later completed reset lands on the old + // bank. The NVCNT never bumps and the new bank never confirms. + let mut mock = revert_scenario(); + + // First pass arms the revert. + assert_eq!(boot_once(&mut mock), BootOutcome::Reverted); + // The reset is interrupted: the staged swap is not applied. + mock.staged_swap = None; + assert_eq!(mock.running(), BankId::Bank2, "still on the new bank"); + assert_eq!(mock.nvcnt, 3); + + // A subsequent clean boot re-arms and, on a completed reset, recovers. + let outcome = drive_to_stable(&mut mock); + assert_eq!(outcome, BootOutcome::HandOff(BankId::Bank1)); + assert_eq!(mock.running(), BankId::Bank1); + assert_eq!(mock.nvcnt, 3, "never bumped across the interrupted reset"); +} + +#[test] +fn steady_state_boot_mutates_nothing() +{ + // A confirmed, healthy bank with a matching NVCNT boots with no persistent + // write at all (no burn, no wear). + let mut mock = MockBootFlash::confirmed( + false, + BankImage::healthy(4, 20), + BankImage::erased(), + 4, + ); + assert_eq!(boot_once(&mut mock), BootOutcome::HandOff(BankId::Bank1)); + assert_eq!(mock.mutations, 0, "steady-state boot writes nothing"); + assert_eq!(mock.nvcnt, 4); + assert_eq!(mock.pending, PendingFlag::None); +} + +#[test] +fn confirmed_but_dead_running_bank_wedges() +{ + let mut mock = MockBootFlash::confirmed( + false, + BankImage::erased(), + BankImage::healthy(3, 20), + 3, + ); + assert_eq!(boot_once(&mut mock), + BootOutcome::Wedge(WedgeReason::NoBootableImage)); +} diff --git a/crates/fw-update/Cargo.toml b/crates/fw-update/Cargo.toml index 22e5c2e..887437a 100644 --- a/crates/fw-update/Cargo.toml +++ b/crates/fw-update/Cargo.toml @@ -23,10 +23,10 @@ _fuzz = [] image-verify = { path = "../image-verify" } [dev-dependencies] -# Host-only fixture minting: the tests sign a synthetic image with a known -# secret scalar so verify_image accepts it. Verify-only on the device, the -# signing path runs solely in tests. -ed25519-dalek = { workspace = true } +# Host-only fixture minting: the tests sign a synthetic image with a known ECDSA +# P-256 scalar so verify_image accepts it. Verify-only on the device, the signing +# path runs solely in tests. +p256 = { workspace = true } [lints] workspace = true diff --git a/crates/fw-update/fuzz/Cargo.lock b/crates/fw-update/fuzz/Cargo.lock index 191f372..cbc7989 100644 --- a/crates/fw-update/fuzz/Cargo.lock +++ b/crates/fw-update/fuzz/Cargo.lock @@ -8,13 +8,25 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -35,87 +47,132 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", + "rand_core", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", + "cmov", "subtle", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "der" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "const-oid", + "zeroize", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "ctutils", ] [[package]] -name = "ed25519" -version = "2.2.3" +name = "ecdsa" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", "signature", + "zeroize", ] [[package]] -name = "ed25519-dalek" -version = "2.2.0" +name = "elliptic-curve" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "curve25519-dalek", - "ed25519", - "sha2", + "base16ct", + "crypto-bigint", + "crypto-common", + "digest", + "ff", + "group", + "hybrid-array", + "rand_core", + "sec1", "subtle", + "zeroize", ] [[package]] -name = "fiat-crypto" -version = "0.2.9" +name = "ff" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] [[package]] name = "find-msvc-tools" @@ -138,16 +195,6 @@ dependencies = [ "libfuzzer-sys", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -160,11 +207,43 @@ dependencies = [ "wasip2", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "image-verify" version = "0.0.1" dependencies = [ - "ed25519-dalek", + "p256", + "sha2", ] [[package]] @@ -194,21 +273,50 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "p256" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "unicode-ident", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", ] [[package]] -name = "quote" -version = "1.0.46" +name = "primefield" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "proc-macro2", + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "wnaf", ] [[package]] @@ -218,25 +326,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rfc6979" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "semver", + "crypto-bigint", + "hmac", ] [[package]] -name = "semver" -version = "1.0.28" +name = "sec1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures", @@ -251,9 +374,13 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest", + "rand_core", +] [[package]] name = "subtle" @@ -261,35 +388,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -304,3 +408,20 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/crates/fw-update/fuzz/fuzz_targets/drive_machine.rs b/crates/fw-update/fuzz/fuzz_targets/drive_machine.rs index ed67876..4e72e33 100644 --- a/crates/fw-update/fuzz/fuzz_targets/drive_machine.rs +++ b/crates/fw-update/fuzz/fuzz_targets/drive_machine.rs @@ -4,7 +4,7 @@ // controlled chunk offsets and lengths against the host mock seam. This covers // the ordering surface this crate adds: an attacker streams chunks, then drives // verify, commit, boot, and confirm. The contract under test: the machine must -// NEVER panic, must reject any malformed or incomplete image, and must NEVER +// never panic, must reject any malformed or incomplete image, and must never // reach the Committed state for an image the verifier did not accept. libFuzzer // feeds mutated byte slices. Any panic/abort is a finding. diff --git a/crates/fw-update/src/fidelity.rs b/crates/fw-update/src/fidelity.rs deleted file mode 100644 index 4b98476..0000000 --- a/crates/fw-update/src/fidelity.rs +++ /dev/null @@ -1,757 +0,0 @@ -//! A fidelity host model of STM32U5 flash, for machine-checked power-fault tests. -//! -//! [`MockFlash`](crate::mock::MockFlash) is enough for the happy-path and -//! single-fault tests, but two silicon-only failure modes are structurally -//! invisible behind it. It writes pages with `copy_from_slice`, which can raise a -//! bit from 0 to 1 (real flash program only clears bits, RM0456 sec 7.3.1), and -//! its `commit_swap` flips the running bank INSTANTLY in the same call, whereas -//! real silicon stages SWAP_BANK and applies it only at the next reset (RM0456 -//! sec 7.5.8). [`FidelityFlash`] models both, so the fault harness can inject a -//! torn write that survives as detectable corruption and a power loss between -//! arming the swap and the reset. -//! -//! # Two physically separate banks -//! -//! Real dual-bank silicon holds two distinct physical bank stores (RM0456 sec -//! 7.5.8). [`PersistentState`] models both as `bank_a` and `bank_b`. `running` -//! selects the bank the firmware boots from (the OLD bank), and `target` selects -//! the inactive bank the update writes. The update flow only ever touches the -//! inactive store, so the OLD store is provably untouched until a swap is -//! confirmed. A staged swap flips which store is `running` at the next reset, the -//! same way the option load does on real silicon. -//! -//! # Persistent versus volatile state -//! -//! Real flash, the NVCNT area, the pending record, the boot counter, and the -//! bank-select option survive a power cut. RAM does not. [`PersistentState`] -//! holds exactly the bytes that survive. A modelled power cut keeps a -//! [`PersistentState`] and drops everything else. A modelled reboot rebuilds a -//! fresh [`FidelityFlash`] from that [`PersistentState`], which is how the -//! harness models the loss of the volatile [`crate::Updater`]. -//! -//! # The cut countdown survives the reset -//! -//! A single global cut index walks EVERY persistent mutation of the whole flow, -//! across the reset boundary. The remaining countdown rides inside -//! [`PersistentState`] so the post-reset [`FidelityFlash`] re-arms it. That lets -//! a cut fire AFTER the reboot, inside on_boot, confirm, or revert, which is -//! where the most safety-critical ordering lives (NVCNT bumped LAST, the SE -//! spend). -//! -//! # The grounded flash semantics this models -//! -//! - Program clears bits only (`new = old AND data`), so a write never raises a -//! bit without an erase (RM0456 sec 7.3.1). -//! - A quad-word is 16 bytes. A torn quad-word write leaves contents not -//! guaranteed (RM0456 sec 7.3.11), and on readback a real double-bit ECC error -//! raises ECCD plus NMI (RM0456 sec 7.3.2). The [`crate::FlashSeam`] returns -//! the bank as `&[u8]` with no fault path, so the host-observable consequence -//! of a torn quad-word is modelled as a poison byte pattern that makes the bank -//! fail image-verify. The verifier rejects the corrupted bank, and the OLD bank -//! boots. -//! - SWAP_BANK plus an option load is atomic at the next reset. The CPU never -//! sees a half-applied option map. A power loss before the option load commits -//! keeps the OLD option values, so the OLD bank boots (RM0456 sec 7.4.2, sec -//! 7.5.8). -//! - The NVCNT area is never erased (WRP plus HDP). A torn bump reads back the -//! old value or the new value, never below the old, because the prior -//! fully-programmed words are untouched and WRP blocks the erase (UM2851 Table -//! 7, Table 8). HONESTY: the bit-level monotone encoding is an INFERENCE -//! consistent with stock MCUboot, NOT a direct RM quote. The property the model -//! relies on is the floor, a torn bump reads back at least the old value. - -#![cfg(test)] - -use crate::seam::BankId; -use crate::seam::FlashError; -use crate::seam::FlashSeam; -use crate::seam::PageIndex; -use crate::seam::PendingFlag; -use crate::seam::SeCounterError; -use crate::seam::SeCounterSeam; -use crate::seam::UpdateOutcome; - -/// The modelled bank size in bytes (each of the two physical banks). -/// -/// Sized to hold a representative update image in host tests. The real bank -/// geometry comes from the hardware-gated flash driver, not this model. -pub const BANK_LEN: usize = 4096; - -/// The flash program granularity in bytes (a quad-word, RM0456 sec 7.3.1). -/// -/// Program acts on a whole quad-word at a time. A torn write corrupts the -/// quad-word it was landing on, which is the granularity the model poisons. -pub const QUAD_WORD_LEN: usize = 16; - -/// The poison byte a torn quad-word reads back as. -/// -/// A torn quad-word write leaves contents not guaranteed (RM0456 sec 7.3.11) and -/// a real readback raises a double-bit ECC fault (RM0456 sec 7.3.2). The seam has -/// no fault path on the byte slice, so the model writes a poison value into the -/// torn quad-word. The host-observable consequence is the same, the verifier -/// rejects the corrupted bank, so the OLD bank boots. -pub const POISON_BYTE: u8 = 0xA5; - -/// The state that survives a power cut. -/// -/// Both physical bank stores, the NVCNT area, the pending record, the boot -/// counter, the bank-select option, and the in-flight cut countdown all live in -/// non-volatile storage from the model's point of view. A modelled power cut -/// keeps this and drops the rest. A modelled reboot rebuilds a [`FidelityFlash`] -/// from it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PersistentState -{ - /// The first physical bank store (program clears bits, erase sets 0xFF). - pub bank_a: [u8; BANK_LEN], - /// The second physical bank store (program clears bits, erase sets 0xFF). - pub bank_b: [u8; BANK_LEN], - /// The monotone NVCNT anti-rollback counter (Gate 1, UM2851 NVCNT). - pub nvcnt: u32, - /// The persistent pending-confirm record. - pub pending: PendingFlag, - /// The persistent update-outcome record (reserved for the boot-stage). - pub outcome: UpdateOutcome, - /// The boot-count confirmation countdown. - pub boot_count: u32, - /// The bank the firmware currently runs from (the OLD bank). - pub running: BankId, - /// The bank the swap would make bootable (the inactive bank). - pub target: BankId, - /// The staged SWAP_BANK target, applied only at the next modelled reset. - /// - /// [`None`] means no swap is staged. [`Some`] holds the bank the option load - /// will select at the next reset. A power cut before the reset keeps the OLD - /// `running`, modelling the atomic-at-reset option load (RM0456 sec 7.5.8). - pub staged_swap: Option, - /// The remaining global cut countdown, carried across the reset boundary. - /// - /// [`None`] means no cut is armed (a clean reboot after a settled run). When - /// a script arms a cut whose index lands after the reset, the surviving - /// countdown rides here so the post-reset model re-arms it and the cut can - /// fire in on_boot, confirm, or revert. - pub cut_countdown: Option, - /// The cut mode that pairs with `cut_countdown`. - pub cut_mode: CutMode, -} - -impl PersistentState -{ - /// Builds a baseline: a valid OLD bank, an erased inactive bank, no swap. - /// - /// The caller fills the inactive bank at begin time. The OLD bank runs from - /// [`BankId::Bank1`] (stored in `bank_a`), the inactive target is - /// [`BankId::Bank2`] (stored in `bank_b`), and no swap is staged. - pub fn baseline(nvcnt: u32) -> PersistentState - { - PersistentState - { - bank_a: [0xFF; BANK_LEN], - bank_b: [0xFF; BANK_LEN], - nvcnt, - pending: PendingFlag::None, - outcome: UpdateOutcome::None, - boot_count: 0, - running: BankId::Bank1, - target: BankId::Bank2, - staged_swap: None, - cut_countdown: None, - cut_mode: CutMode::BeforeMutation, - } - } - - /// Borrows the store backing the given bank id. - pub fn store(&self, bank: BankId) -> &[u8; BANK_LEN] - { - match bank - { - BankId::Bank1 => &self.bank_a, - BankId::Bank2 => &self.bank_b, - } - } - - /// Mutably borrows the store backing the given bank id. - fn store_mut(&mut self, bank: BankId) -> &mut [u8; BANK_LEN] - { - match bank - { - BankId::Bank1 => &mut self.bank_a, - BankId::Bank2 => &mut self.bank_b, - } - } - - /// Applies the staged option load atomically, modelling the swap reset. - /// - /// On a real reset the option load selects the staged bank atomically, then - /// clears the stage. A power cut BEFORE this point keeps the OLD `running`, so - /// the harness only calls this to model a clean reset boundary. After it, - /// `running` is the staged bank and `target` is the other bank. - pub fn apply_reset(&mut self) - { - if let Some(next) = self.staged_swap.take() - { - self.running = next; - self.target = other_bank(next); - } - } -} - -/// The other bank of the two-bank map. -fn other_bank(bank: BankId) -> BankId -{ - match bank - { - BankId::Bank1 => BankId::Bank2, - BankId::Bank2 => BankId::Bank1, - } -} - -/// Where a single power cut lands relative to a persistent mutation. -/// -/// The harness arms a countdown over the persistent mutations the script issues. -/// When the countdown reaches the armed op, the mode decides what the cut does. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CutMode -{ - /// The power dies BEFORE the mutation lands. The op returns an error and the - /// persistent state is unchanged. - BeforeMutation, - /// The power dies AFTER the mutation lands. The op succeeds, then the next - /// call faults, modelling the machine never running past the cut. - AfterMutation, - /// A write tears mid quad-word. For a page write the targeted quad-word is - /// poisoned so the bank fails verify on readback, then the call faults. For a - /// non-write mutation there is no quad-word to poison, so this degrades to - /// [`CutMode::BeforeMutation`] at the mutation site (see each non-write arm). - TornWrite, -} - -/// The outcome of running a script under an armed cut. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CutOutcome -{ - /// The cut never fired (the script issued fewer mutations than the index). - NotReached, - /// The cut fired at the armed mutation. - Fired, -} - -/// A fidelity host model of STM32U5 flash and the staged-swap option state. -/// -/// Implements [`FlashSeam`] modelling the real hardware state, not a per-address -/// queue. It carries an optional armed power cut. When the cut fires it records -/// the outcome and faults the rest of the script. A modelled reboot reads the -/// surviving [`PersistentState`] out, optionally applies the staged swap, then -/// rebuilds a fresh model that re-arms any surviving cut countdown. -pub struct FidelityFlash -{ - persistent: PersistentState, - /// Counts persistent mutations down to the armed cut. [`None`] means no cut - /// is armed, so the model behaves like clean silicon. - countdown: Option, - cut_mode: CutMode, - outcome: CutOutcome, - /// Set once the cut has fired, so every later seam call faults (the machine - /// stopped at the cut on real hardware). - tripped: bool, -} - -impl FidelityFlash -{ - /// Builds a model over the given persistent state, re-arming any surviving - /// cut countdown carried in the state. - /// - /// A fresh script arms its cut with [`FidelityFlash::arm_cut`] after this. A - /// post-reset reboot instead inherits the countdown the previous segment left - /// in [`PersistentState::cut_countdown`], so a single global cut index can - /// fire after the reset. - pub fn new(mut persistent: PersistentState) -> FidelityFlash - { - let countdown = persistent.cut_countdown.take(); - let cut_mode = persistent.cut_mode; - FidelityFlash - { - persistent, - countdown, - cut_mode, - outcome: CutOutcome::NotReached, - tripped: false, - } - } - - /// Arms a single power cut at the `index`-th persistent mutation. - /// - /// `index` counts mutating seam calls from zero, over the WHOLE flow. `mode` - /// decides whether the cut lands before the mutation, after it, or tears a - /// write. The cut fires at most once across the whole flow, even across the - /// reset, because the surviving countdown rides in the persistent state. - pub fn arm_cut(&mut self, index: u32, mode: CutMode) - { - self.countdown = Some(index); - self.cut_mode = mode; - } - - /// The recorded cut outcome. - pub fn outcome(&self) -> CutOutcome - { - self.outcome - } - - /// Borrows the persistent state (test inspection only). - /// - /// The harness reads the surviving state out through this to model a reboot. - /// The returned state carries any unspent cut countdown, so the next - /// [`FidelityFlash::new`] re-arms the cut after the reset. - pub fn persistent(&self) -> &PersistentState - { - &self.persistent - } - - /// Reads the surviving persistent state out, modelling a power cut. - /// - /// A cut does NOT apply a staged swap. The surviving state carries the - /// remaining cut countdown so a post-reset model re-arms it. - pub fn into_surviving(self) -> PersistentState - { - let mut surviving = self.persistent; - // Carry the remaining countdown across the reset only if the cut has not - // already fired in this segment. Once fired, the cut is spent. - if self.tripped - { - surviving.cut_countdown = None; - } - else - { - surviving.cut_countdown = self.countdown; - surviving.cut_mode = self.cut_mode; - } - surviving - } - - /// Steps the cut countdown for one persistent mutation. - /// - /// Returns the action the caller must take. On a clean step the caller - /// performs the mutation. On a fired cut the caller honours the mode. - fn step_cut(&mut self) -> CutAction - { - if self.tripped - { - // A cut already fired, so the machine never reached this op on real - // hardware. Every later mutation faults. - return CutAction::Fault; - } - match self.countdown - { - None => CutAction::Proceed, - Some(0) => - { - self.outcome = CutOutcome::Fired; - self.tripped = true; - self.countdown = None; - match self.cut_mode - { - CutMode::BeforeMutation => CutAction::Fault, - CutMode::AfterMutation => CutAction::MutateThenStop, - CutMode::TornWrite => CutAction::Tear, - } - } - Some(n) => - { - self.countdown = Some(n - 1); - CutAction::Proceed - } - } - } - - /// Poisons the quad-word a torn write was landing on. - /// - /// A torn quad-word write corrupts that quad-word (RM0456 sec 7.3.11), so the - /// bank fails verify on readback. The model writes [`POISON_BYTE`] across the - /// quad-word aligned to `start`, clamped to the inactive bank store. - fn poison_quad_word(&mut self, start: usize) - { - let aligned = start - (start % QUAD_WORD_LEN); - let end = core::cmp::min(aligned + QUAD_WORD_LEN, BANK_LEN); - let target = self.persistent.target; - let store = self.persistent.store_mut(target); - if let Some(slot) = store.get_mut(aligned..end) - { - for byte in slot.iter_mut() - { - *byte = POISON_BYTE; - } - } - } -} - -/// What a cut step tells a mutating seam method to do. -enum CutAction -{ - /// No cut here. Perform the mutation normally. - Proceed, - /// The cut landed before the mutation. Fault, leave state unchanged. - Fault, - /// The cut landed after the mutation. Perform it, then the model is tripped - /// so later calls fault. - MutateThenStop, - /// A write tears. Poison the targeted quad-word, then fault. - Tear, -} - -impl FlashSeam for FidelityFlash -{ - fn inactive_bank(&self) -> &[u8] - { - self.persistent.store(self.persistent.target) - } - - fn erase_inactive(&mut self) -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - let target = self.persistent.target; - *self.persistent.store_mut(target) = [0xFF; BANK_LEN]; - Ok(()) - } - // For an erase there is no targeted quad-word, so a TornWrite cut - // degrades to BeforeMutation here: the erase faults, state unchanged. - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::Hardware) - } - } - } - - fn write_inactive_page - ( - &mut self, - page: PageIndex, - data: &[u8], - ) - -> Result<(), FlashError> - { - let start = (page as usize) - .checked_mul(crate::machine::PAGE_LEN) - .ok_or(FlashError::OutOfRange)?; - let end = start - .checked_add(data.len()) - .ok_or(FlashError::OutOfRange)?; - if end > BANK_LEN - { - return Err(FlashError::OutOfRange); - } - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - let target = self.persistent.target; - program_clears_bits( - self.persistent.store_mut(target), - start, - data, - )?; - Ok(()) - } - CutAction::Fault => - { - Err(FlashError::WriteFailed) - } - CutAction::Tear => - { - // Program a torn quad-word: the contents are not guaranteed - // (RM0456 sec 7.3.11), modelled as a poisoned quad-word that - // fails verify on readback (RM0456 sec 7.3.2). - self.poison_quad_word(start); - Err(FlashError::WriteFailed) - } - } - } - - fn running_bank(&mut self) -> Result - { - Ok(self.persistent.running) - } - - fn target_bank(&mut self) -> Result - { - Ok(self.persistent.target) - } - - fn commit_swap(&mut self) -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - // Stage the swap. It applies only at the next modelled reset - // (RM0456 sec 7.5.8), so running_bank does NOT change here. - self.persistent.staged_swap = Some(self.persistent.target); - Ok(()) - } - // commit_swap is an option-program arm, not a quad-word write, so a - // TornWrite cut degrades to BeforeMutation here: it faults, no swap. - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::Hardware) - } - } - } - - fn revert_swap(&mut self) -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - // Stage a reverse swap back to the previously running bank. It - // too applies only at the next modelled reset. - let back = other_bank(self.persistent.running); - self.persistent.staged_swap = Some(back); - Ok(()) - } - // revert_swap is an option-program arm, not a quad-word write, so a - // TornWrite cut degrades to BeforeMutation here: it faults, no swap. - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::Hardware) - } - } - } - - fn nvcnt_read(&mut self) -> Result - { - Ok(self.persistent.nvcnt) - } - - fn nvcnt_bump(&mut self, value: u32) -> Result<(), FlashError> - { - if value < self.persistent.nvcnt - { - return Err(FlashError::WriteFailed); - } - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - self.persistent.nvcnt = value; - Ok(()) - } - CutAction::Fault => - { - // A cut before the bump lands keeps the OLD counter. The NVCNT - // area is never erased, so it reads back the prior value. - Err(FlashError::WriteFailed) - } - CutAction::Tear => - { - // A torn bump reads back the old value OR the new value, never - // below the old (UM2851 NVCNT, the monotone floor). The model - // keeps the old value, so the torn result equals the - // BeforeMutation outcome and still satisfies the floor, then it - // faults. - Err(FlashError::WriteFailed) - } - } - } - - fn pending_read(&mut self) -> Result - { - Ok(self.persistent.pending) - } - - fn pending_write(&mut self, flag: PendingFlag) -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - self.persistent.pending = flag; - Ok(()) - } - // The pending record is a word write, not a quad-word image write, so - // a TornWrite cut degrades to BeforeMutation here: it faults, the - // record keeps its prior value. - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::WriteFailed) - } - } - } - - fn boot_count_read(&mut self) -> Result - { - Ok(self.persistent.boot_count) - } - - fn boot_count_advance(&mut self) -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - self.persistent.boot_count = self - .persistent - .boot_count - .checked_add(1) - .ok_or(FlashError::WriteFailed)?; - Ok(()) - } - // The boot count is a word write, so a TornWrite cut degrades to - // BeforeMutation here: it faults, the count keeps its prior value. - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::WriteFailed) - } - } - } - - fn update_outcome_read(&mut self) -> Result - { - Ok(self.persistent.outcome) - } - - fn update_outcome_write - ( - &mut self, - outcome: UpdateOutcome, - ) - -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - self.persistent.outcome = outcome; - Ok(()) - } - // The outcome record is a word write, not a quad-word image write, so - // a TornWrite cut degrades to BeforeMutation here: it faults, the - // record keeps its prior value. - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::WriteFailed) - } - } - } - - fn update_outcome_clear(&mut self) -> Result<(), FlashError> - { - match self.step_cut() - { - CutAction::Proceed | CutAction::MutateThenStop => - { - self.persistent.outcome = UpdateOutcome::None; - Ok(()) - } - CutAction::Fault | CutAction::Tear => - { - Err(FlashError::WriteFailed) - } - } - } -} - -/// A fidelity host model of the secure-element down-counter (Gate 2). -/// -/// [`crate::mock::MockSeCounter`] is enough for the simple tests, but it has no -/// switch to drop the channel mid-spend. This model adds one, so the harness can -/// inject a channel drop on the [`SeCounterSeam::update`] call itself, the spend -/// window between the SE decrement and the NVCNT bump in confirm (machine.rs -/// confirm). The model proves the recovery on the next boot does not double-spend -/// the counter and does not strand a half-confirmed state. -pub struct FidelitySeCounter -{ - value: u32, - updated: bool, - /// When `true`, the next [`SeCounterSeam::update`] drops the channel and the - /// counter does NOT decrement, modelling a power loss or channel drop during - /// the spend. - drop_on_update: bool, -} - -impl FidelitySeCounter -{ - /// Builds a counter at `value`, channel up, no drop armed. - pub fn new(value: u32) -> FidelitySeCounter - { - FidelitySeCounter - { - value, - updated: false, - drop_on_update: false, - } - } - - /// Arms a channel drop on the next [`SeCounterSeam::update`]. - pub fn arm_drop_on_update(&mut self) - { - self.drop_on_update = true; - } - - /// True once [`SeCounterSeam::update`] decremented the counter. - pub fn updated(&self) -> bool - { - self.updated - } - - /// The current modelled value (test inspection only). - pub fn value(&self) -> u32 - { - self.value - } -} - -impl SeCounterSeam for FidelitySeCounter -{ - fn read(&mut self) -> Result - { - Ok(self.value) - } - - fn update(&mut self) -> Result<(), SeCounterError> - { - if self.drop_on_update - { - // The channel dropped during the spend. The counter does not - // decrement, so the next boot reads the same value and the recovery - // retries the spend without a double decrement. - return Err(SeCounterError::Unavailable); - } - let next = self - .value - .checked_sub(1) - .ok_or(SeCounterError::Exhausted)?; - self.value = next; - self.updated = true; - Ok(()) - } -} - -/// Programs `data` into `bank` at `start`, clearing bits only (RM0456 sec 7.3.1). -/// -/// `new = old AND data`, so a write never raises a bit from 0 to 1. A real -/// reprogram of a non-zero word raises PROGERR, but for the host-observable bank -/// readback the AND-mask captures the property the verifier depends on, a write -/// can only clear bits an erase set. -fn program_clears_bits -( - bank: &mut [u8; BANK_LEN], - start: usize, - data: &[u8], -) - -> Result<(), FlashError> -{ - let end = start - .checked_add(data.len()) - .ok_or(FlashError::OutOfRange)?; - let slot = bank - .get_mut(start..end) - .ok_or(FlashError::OutOfRange)?; - for (cell, byte) in slot.iter_mut().zip(data.iter()) - { - *cell &= *byte; - } - Ok(()) -} diff --git a/crates/fw-update/src/lib.rs b/crates/fw-update/src/lib.rs index f8c978b..c8b6954 100644 --- a/crates/fw-update/src/lib.rs +++ b/crates/fw-update/src/lib.rs @@ -9,10 +9,10 @@ //! # Mocked dangerous seam //! //! Every irreversible or brick-risk operation (a flash write, an erase, a -//! SWAP_BANK flip, an option load) is reachable ONLY through [`FlashSeam`]. This -//! crate ships the trait, the state machine, and a HOST MOCK ([`mock`], gated to -//! tests and the fuzz harness). It emits NO real flash write, NO real erase, NO -//! real SWAP_BANK write, NO option-byte write, and NO OBL_LAUNCH. The real +//! SWAP_BANK flip, an option load) is reachable only through [`FlashSeam`]. This +//! crate ships the trait, the state machine, and a host mock ([`mock`], gated to +//! tests and the fuzz harness). It emits no real flash write, no real erase, no +//! real SWAP_BANK write, no option-byte write, and no OBL_LAUNCH. The real //! volatile-flash MMIO driver is a separate hardware-gated crate. //! //! # Anti-rollback @@ -22,14 +22,12 @@ //! is rejected. A confirmed update bumps the counter. Gate 2 (the TROPIC01 //! down-counter): after the secure channel is up, a monotonic secure-element //! counter gates the key-ops accept, not the boot decision. Both gate values are -//! trusted only after the Ed25519 signature verifies, because the counter lives -//! in the signed region. +//! trusted only after the image signature verifies, because the counter lives in +//! the signed region. #![cfg_attr(not(test), no_std)] #![forbid(unsafe_code)] -#[cfg(test)] -mod fidelity; mod machine; mod mock; mod seam; @@ -58,21 +56,24 @@ pub use crate::mock::MockFlash; #[cfg(any(test, feature = "_fuzz"))] pub use crate::mock::MockSeCounter; -/// A DEV / placeholder Ed25519 root public key for host tests. +/// A dev root public key, test only. /// -/// This is NOT the production key. The secure binary pins the genuine public key -/// out-of-band as a const when the boot flow lands. Compiling a PUBLIC key into -/// the firmware is fully reversible by a reflash. It is NOT the irreversible -/// TROPIC01 pairing-key write, so no brick rule is triggered. The verify path -/// takes the key as input, so this library stays testable. +/// The uncompressed SEC1 P-256 public key of the all-`0x01` private scalar. +/// It is gated to `cfg(test)` and the `_fuzz` feature. /// -/// The bytes are the public key of the all-`0x01` Ed25519 secret scalar, an -/// on-curve point that `RootKey::from_bytes` accepts. -pub const DEV_ROOT_KEY: [u8; 32] = [ - 0x8a, 0x88, 0xe3, 0xdd, 0x74, 0x09, 0xf1, 0x95, - 0xfd, 0x52, 0xdb, 0x2d, 0x3c, 0xba, 0x5d, 0x72, - 0xca, 0x67, 0x09, 0xbf, 0x1d, 0x94, 0x12, 0x1b, - 0xf3, 0x74, 0x88, 0x01, 0xb4, 0x0f, 0x6f, 0x5c, +/// The production key is pinned by the boot stage. A build that +/// forgets a feature flag must never fall back to this. +#[cfg(any(test, feature = "_fuzz"))] +pub const DEV_ROOT_KEY_TEST_ONLY: [u8; image_verify::ROOT_KEY_LEN] = [ + 0x04, 0x6f, 0xf0, 0x3b, 0x94, 0x92, 0x41, 0xce, + 0x1d, 0xad, 0xd4, 0x35, 0x19, 0xe6, 0x96, 0x0e, + 0x0a, 0x85, 0xb4, 0x1a, 0x69, 0xa0, 0x5c, 0x32, + 0x81, 0x03, 0xaa, 0x2b, 0xce, 0x15, 0x94, 0xca, + 0x16, 0x3c, 0x4f, 0x75, 0x3a, 0x55, 0xbf, 0x01, + 0xdc, 0x53, 0xf6, 0xc0, 0xb0, 0xc7, 0xee, 0xe7, + 0x8b, 0x40, 0xc6, 0xff, 0x7d, 0x25, 0xa9, 0x6e, + 0x22, 0x82, 0xb9, 0x89, 0xce, 0xf7, 0x1c, 0x14, + 0x4a, ]; /// The fuzz seam over the receive -> verify -> commit state machine. @@ -98,7 +99,7 @@ pub mod fuzz /// so the path under test is the fail-closed rejection across the whole flow. pub fn drive_machine(data: &[u8]) { - let root = match RootKey::from_bytes(crate::DEV_ROOT_KEY) + let root = match RootKey::from_bytes(crate::DEV_ROOT_KEY_TEST_ONLY) { Ok(key) => key, Err(_) => return, @@ -164,7 +165,7 @@ pub mod fuzz } #[cfg(test)] -mod tests; +mod test_fixtures; #[cfg(test)] -mod power_fault; +mod tests; diff --git a/crates/fw-update/src/machine.rs b/crates/fw-update/src/machine.rs index 07c3248..9b910a0 100644 --- a/crates/fw-update/src/machine.rs +++ b/crates/fw-update/src/machine.rs @@ -1,63 +1,73 @@ //! The dual-bank A/B update state machine for the MCU's own firmware. //! -//! The machine streams an update image THROUGH the seam into the inactive bank, +//! The machine streams an update image through the seam into the inactive bank, //! verifies the bank by reading it back, runs the two anti-rollback gates, //! commits the swap (modelled, RM0456 sec 7.5.8), then confirms or reverts on //! the first boot of the new bank. //! //! # The inactive bank is the single source of truth //! -//! [`Updater::receive_chunk`] writes accepted bytes into the inactive bank -//! through [`FlashSeam::write_inactive_page`]. No whole-image RAM copy exists. A -//! small page-assembly buffer holds the page under construction until it fills, -//! then the seam flushes it. [`Updater::verify_and_accept`] reads the bank back -//! through [`FlashSeam::inactive_bank`] and passes THOSE bytes to -//! [`image_verify::verify_image`]. The swap commits THAT same bank, so the bytes -//! verified are the bytes booted, by construction. +//! The signed file stays contiguous `header || payload || signature`, but the +//! device de-interleaves it so the payload lands page-aligned at its link origin +//! and the committed bank is bootable-shaped. [`Updater::receive_chunk`] routes +//! each stream byte to its destination: the header and the signature into small +//! fixed buffers, the payload into the inactive bank through +//! [`FlashSeam::write_inactive_page`] (which addresses the payload band, page 10 +//! onward). No whole-image RAM copy exists, only the 24-byte header, the 64-byte +//! signature, and one payload page under construction. At accept +//! [`Updater::verify_and_accept`] writes the descriptor page (header then +//! signature) through [`FlashSeam::write_descriptor`], then reads four segments +//! back through [`FlashSeam::inactive_descriptor`], +//! [`FlashSeam::inactive_secure_band`], and [`FlashSeam::inactive_ns_band`] and +//! passes them to [`image_verify::verify_image`]. Their concatenation is the +//! original file. The swap commits that same bank, so the bytes verified are the +//! bytes booted. //! //! # Fail-closed by construction //! -//! Every transition that touches an irreversible seam op runs ONLY on the Ok +//! Every transition that touches an irreversible seam op runs only on the Ok //! path of the prior check. A verify failure, an anti-rollback violation, or any -//! seam error collapses the machine to a state that keeps the OLD bank bootable. +//! seam error collapses the machine to a state that keeps the old bank bootable. //! No field inside the signed region is trusted before the signature verifies: //! the [`image_verify::VerifiedImage`] the gates read exists only after the -//! Ed25519 check passed. +//! ECDSA P-256 check passed. //! //! # Power-loss reasoning at each step boundary //! -//! No ordering loses the OLD bank's bootability (RM0456 sec 7.5.8: SWAP_BANK +//! No ordering loses the old bank's bootability (RM0456 sec 7.5.8: SWAP_BANK //! takes effect atomically at the next reset, the CPU never sees a half-swapped -//! map, a power loss before the swap commits leaves the OLD bank booting). +//! map, a power loss before the swap commits leaves the old bank booting). //! //! - During receive or page write: the inactive bank is partly written, the -//! running bank is untouched, no swap is armed. The next boot runs the OLD +//! running bank is untouched, no swap is armed. The next boot runs the old //! bank. A fresh update restarts from erase. //! - Between accept and commit: the machine holds [`UpdateState::PendingCommit`] //! in volatile state only. No persistent flag is set. A power loss drops the -//! accept and the next boot runs the OLD bank. -//! - [`Updater::commit`] writes [`PendingFlag::Armed`] with the TARGET bank id -//! BEFORE [`FlashSeam::commit_swap`]. That call resets the part on real +//! accept and the next boot runs the old bank. +//! - [`Updater::commit`] writes [`PendingFlag::Armed`] with the target bank id +//! before [`FlashSeam::commit_swap`]. That call resets the part on real //! hardware, so the confirm-owed marker must already be persisted when the new //! bank first runs. A power loss in this window leaves [`PendingFlag::Armed`] -//! set but the swap NOT yet effective, so the OLD bank still boots. +//! set but the swap not yet effective, so the old bank still boots. //! - First boot after the commit: [`Updater::on_boot`] compares the running -//! bank against the armed target. If they MATCH, the swap took effect and a -//! confirm is owed ([`UpdateState::AwaitingConfirm`]). If they DIFFER, the swap -//! never took effect (power loss before the option load committed), so the -//! machine clears the record and stays on the OLD bank. It never arms a reverse +//! bank against the armed target. A match means the swap took effect and a +//! confirm is owed ([`UpdateState::AwaitingConfirm`]). A mismatch means the +//! swap never took effect (power loss before the option load committed), so the +//! machine clears the record and stays on the old bank. It never arms a reverse //! swap toward an unverified bank. -//! - [`Updater::revert`] is reachable ONLY from [`UpdateState::AwaitingConfirm`], -//! which on_boot enters ONLY when the forward swap is proven effective. A +//! - [`Updater::revert`] is reachable only from [`UpdateState::AwaitingConfirm`], +//! which on_boot enters only when the forward swap is proven effective. A //! revert therefore flips back only from a swap known to have committed. If the -//! swap never took effect, revert is unreachable and the OLD bank already boots. -//! - [`Updater::confirm`] bumps Gate-1 NVCNT LAST, at the terminal +//! swap never took effect, revert is unreachable and the old bank already boots. +//! - [`Updater::confirm`] bumps Gate-1 NVCNT last, at the terminal //! [`UpdateState::Confirmed`] transition, past every revert decision. Once a //! confirm step runs, the state leaves [`UpdateState::AwaitingConfirm`], so //! revert can no longer fire. The machine cannot both bump NVCNT and later //! revert, so NVCNT never rises above a bank that gets reverted away. +use image_verify::HEADER_LEN; use image_verify::RootKey; +use image_verify::SIG_LEN; use image_verify::VerifyError; use image_verify::verify_image; @@ -82,12 +92,12 @@ pub const CONFIRM_BOOTS: u32 = 1; /// The anti-rollback origin the secure-element down-counter maps from (Gate 2). /// -/// The TROPIC01 MCounter counts DOWN from a provisioned start: each accepted -/// update spends one tick (MCounter_Update). The machine derives an -/// anti-rollback FLOOR as `SE_COUNTER_ORIGIN - se_value`: the more ticks spent, -/// the higher the floor. An image whose signed security counter sits below that -/// floor is a rollback below what the secure element already accepted, so the -/// machine rejects the accept. The origin is the provisioned start value. +/// The TROPIC01 MCounter counts down from a provisioned start: each accepted +/// update spends one tick (MCounter_Update). The machine derives an anti-rollback +/// floor as `SE_COUNTER_ORIGIN - se_value`: the more ticks spent, the higher the +/// floor. An image whose signed security counter sits below that floor is a +/// rollback below what the secure element already accepted, so the machine +/// rejects the accept. The origin is the provisioned start value. pub const SE_COUNTER_ORIGIN: u32 = 0xFFFF_FFFF; /// The update state. @@ -95,7 +105,7 @@ pub const SE_COUNTER_ORIGIN: u32 = 0xFFFF_FFFF; /// The flow is Idle -> ReceivingChunks -> VerifyingImage -> (Rejected | /// PendingCommit) -> Committed -> BootingNew -> AwaitingConfirm -> (Confirmed | /// Reverted). Rejected and Reverted both return to a safe resting state with the -/// OLD bank bootable. +/// old bank bootable. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateState { @@ -120,7 +130,7 @@ pub enum UpdateState Confirming, /// The new bank confirmed. NVCNT bumped, SE counter spent, record cleared. Confirmed, - /// Confirmation timed out. The machine reverted the swap to the OLD bank. + /// Confirmation timed out. The machine reverted the swap to the old bank. Reverted, } @@ -177,6 +187,9 @@ pub struct Updater<'k, F, S> se_counter: S, total_len: usize, written: usize, + payload_len: usize, + header_buf: [u8; HEADER_LEN], + sig_buf: [u8; SIG_LEN], page_buf: [u8; PAGE_LEN], } @@ -202,6 +215,9 @@ where se_counter, total_len: 0, written: 0, + payload_len: 0, + header_buf: [0xFF; HEADER_LEN], + sig_buf: [0xFF; SIG_LEN], page_buf: [0xFF; PAGE_LEN], } } @@ -225,16 +241,6 @@ where &self.flash } - /// Consumes the updater and returns the flash seam (test inspection only). - /// - /// The power-fault harness uses this to model a reboot: it drops the volatile - /// updater and reads the surviving persistent state out of the flash seam. - #[cfg(test)] - pub(crate) fn into_flash(self) -> F - { - self.flash - } - /// Borrows the secure-element counter seam (test inspection only). #[cfg(test)] pub(crate) fn se_counter(&self) -> &S @@ -251,19 +257,21 @@ where /// Starts an update: erases the inactive bank and arms the accumulator. /// - /// The caller declares `total_len`, the exact byte count the full image - /// occupies. [`Self::verify_and_accept`] rejects unless the bytes written to - /// the inactive bank match it, so a prefix can never reach verify. + /// The caller declares `total_len`, the exact byte count the full signed file + /// occupies (`header || payload || signature`). [`Self::verify_and_accept`] + /// rejects unless the bytes written match it, so a prefix can never reach + /// verify. The header and signature de-interleave into the descriptor, so the + /// payload (the raw firmware) is what must fit the payload band. /// /// # Arguments /// - /// - `total_len`: the declared total image length in bytes. + /// - `total_len`: the declared total file length in bytes. /// /// # Errors /// /// [`UpdateError::BadState`] unless the machine is [`UpdateState::Idle`], - /// [`UpdateError::ChunkOutOfRange`] if `total_len` exceeds the inactive bank, - /// [`UpdateError::Flash`] if the erase fails (the machine stays Idle, OLD + /// [`UpdateError::ChunkOutOfRange`] if the payload exceeds the payload band, + /// [`UpdateError::Flash`] if the erase fails (the machine stays Idle, old /// bank bootable). pub fn begin(&mut self, total_len: usize) -> Result<(), UpdateError> { @@ -271,13 +279,22 @@ where { return Err(UpdateError::BadState); } - if total_len > self.flash.inactive_bank().len() + let payload_capacity = self + .flash + .inactive_secure_band() + .len() + .saturating_add(self.flash.inactive_ns_band().len()); + let payload_len = total_len.saturating_sub(HEADER_LEN + SIG_LEN); + if payload_len > payload_capacity { return Err(UpdateError::ChunkOutOfRange); } self.flash.erase_inactive()?; self.total_len = total_len; + self.payload_len = payload_len; self.written = 0; + self.header_buf = [0xFF; HEADER_LEN]; + self.sig_buf = [0xFF; SIG_LEN]; self.page_buf = [0xFF; PAGE_LEN]; self.state = UpdateState::ReceivingChunks; Ok(()) @@ -330,12 +347,17 @@ where } } - /// Streams one contiguous chunk into the inactive bank, page by page. + /// De-interleaves one contiguous chunk of the signed file onto flash. /// - /// Requires `offset` to equal [`Self::written`] (in-order, no gap). Fills the - /// page-assembly buffer, flushes each full page through the seam, and tracks - /// the running written count. Leaves a partial trailing page buffered until - /// [`Self::flush_partial_page`] writes it at accept time. + /// Requires `offset` to equal [`Self::written`] (in-order, no gap). The file + /// is `header || payload || signature`, so a stream offset routes to one of + /// three destinations, splitting the chunk at each region boundary: the header + /// range [0, `HEADER_LEN`) buffers until the descriptor is written, the payload + /// range [`HEADER_LEN`, `HEADER_LEN` + payload_len) streams into the payload + /// band page by page, and the tail signature buffers until the descriptor is + /// written. The header and signature buffers plus the descriptor page write are + /// how the magic lands off the secure app link origin. Each region run advances + /// by at least one byte, so the loop always terminates. /// /// # Errors /// @@ -360,47 +382,131 @@ where { return Err(UpdateError::ChunkOutOfRange); } + // The payload region ends HEADER_LEN + payload_len bytes into the stream. + // The signature region runs from there to total_len. + let payload_end = HEADER_LEN + .checked_add(self.payload_len) + .ok_or(UpdateError::ChunkOutOfRange)?; let mut rest = data; while !rest.is_empty() { - let page_fill = self.written % PAGE_LEN; + let pos = self.written; + let take = if pos < HEADER_LEN + { + // Header region: buffer into header_buf, never touch flash yet. + let room = HEADER_LEN - pos; + let take = core::cmp::min(room, rest.len()); + let slot = self + .header_buf + .get_mut(pos..pos + take) + .ok_or(UpdateError::ChunkOutOfRange)?; + let head = rest + .get(..take) + .ok_or(UpdateError::ChunkOutOfRange)?; + slot.copy_from_slice(head); + take + } + else if pos < payload_end + { + // Payload region: stream into the payload band page by page. + let room = payload_end - pos; + let take = core::cmp::min(room, rest.len()); + let payload_pos = pos - HEADER_LEN; + let head = rest + .get(..take) + .ok_or(UpdateError::ChunkOutOfRange)?; + self.write_payload_run(payload_pos, head)?; + take + } + else + { + // Signature region: buffer into sig_buf, never touch flash yet. + let sig_pos = pos - payload_end; + let room = SIG_LEN + .checked_sub(sig_pos) + .ok_or(UpdateError::ChunkOutOfRange)?; + let take = core::cmp::min(room, rest.len()); + let slot = self + .sig_buf + .get_mut(sig_pos..sig_pos + take) + .ok_or(UpdateError::ChunkOutOfRange)?; + let head = rest + .get(..take) + .ok_or(UpdateError::ChunkOutOfRange)?; + slot.copy_from_slice(head); + take + }; + self.written = self + .written + .checked_add(take) + .ok_or(UpdateError::ChunkOutOfRange)?; + rest = rest + .get(take..) + .ok_or(UpdateError::ChunkOutOfRange)?; + } + Ok(()) + } + + /// Streams a contiguous payload run into the payload band, page by page. + /// + /// `payload_pos` is the run's byte offset inside the payload (not the file). + /// Fills the page-assembly buffer and flushes each full page through + /// [`FlashSeam::write_inactive_page`], which addresses the payload band (page + /// 10 onward). A partial trailing page stays buffered until + /// [`Self::flush_partial_payload_page`] writes it at accept time. + /// + /// # Errors + /// + /// [`UpdateError::ChunkOutOfRange`] on an internal bound slip, + /// [`UpdateError::Flash`] on a page-write fault. + fn write_payload_run + ( + &mut self, + payload_pos: usize, + data: &[u8], + ) + -> Result<(), UpdateError> + { + let mut rest = data; + let mut pos = payload_pos; + while !rest.is_empty() + { + let page_fill = pos % PAGE_LEN; let room = PAGE_LEN - page_fill; let take = core::cmp::min(room, rest.len()); - let (head, tail) = rest - .split_at_checked(take) + let head = rest + .get(..take) .ok_or(UpdateError::ChunkOutOfRange)?; let slot = self .page_buf .get_mut(page_fill..page_fill + take) .ok_or(UpdateError::ChunkOutOfRange)?; slot.copy_from_slice(head); - self.written = self - .written + pos = pos .checked_add(take) .ok_or(UpdateError::ChunkOutOfRange)?; - rest = tail; - if self.written.is_multiple_of(PAGE_LEN) + rest = rest + .get(take..) + .ok_or(UpdateError::ChunkOutOfRange)?; + if pos.is_multiple_of(PAGE_LEN) { - self.flush_full_page()?; + let page = (pos / PAGE_LEN) + .checked_sub(1) + .ok_or(UpdateError::ChunkOutOfRange)?; + self.flush_payload_page(page)?; } } Ok(()) } - /// Writes the just-filled page-assembly buffer to its bank page. - /// - /// Called once a page boundary is reached. Derives the page index from the - /// bytes written so far, then resets the buffer for the next page. + /// Writes the just-filled payload page to its band page, then resets the + /// buffer. /// /// # Errors /// /// [`UpdateError::Flash`] on a page-write fault. - fn flush_full_page(&mut self) -> Result<(), UpdateError> + fn flush_payload_page(&mut self, page: usize) -> Result<(), UpdateError> { - let page = self.written / PAGE_LEN; - let page = page - .checked_sub(1) - .ok_or(UpdateError::ChunkOutOfRange)?; let index = PageIndex::try_from(page) .map_err(|_| UpdateError::ChunkOutOfRange)?; self.flash.write_inactive_page(index, &self.page_buf)?; @@ -408,22 +514,22 @@ where Ok(()) } - /// Writes a partial trailing page so the bank holds every accepted byte. + /// Writes a partial trailing payload page so the band holds every byte. /// - /// Called once, at accept time, when the declared length does not land on a - /// page boundary. Writes only the buffered bytes of the final page. + /// Called once, at accept time, when the payload does not land on a page + /// boundary. Writes only the buffered bytes of the final payload page. /// /// # Errors /// /// [`UpdateError::Flash`] on a page-write fault. - fn flush_partial_page(&mut self) -> Result<(), UpdateError> + fn flush_partial_payload_page(&mut self) -> Result<(), UpdateError> { - let page_fill = self.written % PAGE_LEN; + let page_fill = self.payload_len % PAGE_LEN; if page_fill == 0 { return Ok(()); } - let page = self.written / PAGE_LEN; + let page = self.payload_len / PAGE_LEN; let index = PageIndex::try_from(page) .map_err(|_| UpdateError::ChunkOutOfRange)?; let slice = self @@ -434,13 +540,29 @@ where Ok(()) } + /// Assembles the descriptor bytes: the buffered header then the signature. + /// + /// The result is exactly `HEADER_LEN` + `SIG_LEN` bytes, which the descriptor + /// page holds at [0:24] and [24:88]. + fn descriptor_bytes(&self) -> [u8; HEADER_LEN + SIG_LEN] + { + let mut out = [0xFF; HEADER_LEN + SIG_LEN]; + let (head, tail) = out.split_at_mut(HEADER_LEN); + head.copy_from_slice(&self.header_buf); + tail.copy_from_slice(&self.sig_buf); + out + } + /// Verifies the inactive bank and runs the two anti-rollback gates. /// - /// Flushes any partial trailing page, checks the written byte count against - /// the declared length (the completeness gate), then reads the inactive bank - /// back through [`FlashSeam::inactive_bank`] and passes exactly those bytes - /// to [`image_verify::verify_image`] under the pinned root key. The swap - /// commits this same bank, so verify and commit act on the same bytes. + /// Flushes any partial trailing payload page, checks the written byte count + /// against the declared length (the completeness gate), writes the descriptor + /// page from the buffered header and signature, then reads the inactive bank + /// back as four segments through [`FlashSeam::inactive_descriptor`], + /// [`FlashSeam::inactive_secure_band`], and [`FlashSeam::inactive_ns_band`] + /// and passes them to [`image_verify::verify_image`] under the pinned root + /// key. The swap commits this same bank, so verify and commit act on the same + /// bytes. /// /// On success it runs Gate 1 (UM2851 NVCNT) and Gate 2 (the TROPIC01 /// down-counter floor). Both reject an image whose signed security counter @@ -448,7 +570,7 @@ where /// the security counter lives in the signed region and verify already passed. /// /// On any failure the machine clears the inactive bank and collapses to - /// [`UpdateState::Rejected`] then [`UpdateState::Idle`], with the OLD bank + /// [`UpdateState::Rejected`] then [`UpdateState::Idle`], with the old bank /// bootable and no swap armed. /// /// # Errors @@ -465,18 +587,28 @@ where return Err(UpdateError::BadState); } - if let Err(error) = self.flush_partial_page() + if let Err(error) = self.flush_partial_payload_page() { return self.reject(error); } - // The completeness gate: the bytes written to the inactive bank must - // match the declared length, so a prefix never reaches verify. + // The completeness gate: the bytes written must match the declared file + // length, so a prefix never reaches verify. if self.written != self.total_len { return self.reject(UpdateError::Incomplete); } + // Write the descriptor page (header at [0:24], signature at [24:88]) from + // the buffered header and signature. The verify below reads it back, so + // the whole image, including header and signature, is read from the store + // the commit boots. + if let Err(error) = + self.flash.write_descriptor(&self.descriptor_bytes()) + { + return self.reject(UpdateError::Flash(error)); + } + self.state = UpdateState::VerifyingImage; // Gate 1 (UM2851 NVCNT) and the secure-element value are read before the @@ -492,22 +624,50 @@ where Err(error) => return self.reject(UpdateError::SeCounter(error)), }; - // Verify the EXACT bytes the swap will boot, read back from the bank. - let bank = self.flash.inactive_bank(); - let image = match bank.get(..self.total_len) + // Verify the exact bytes the swap will boot, read back from the store. + // The verifier takes a segmented image. The seam hands back four logical + // segments read through their own aliases: the header and signature from + // the descriptor (secure via 0x0C..), the secure payload (0x0C..), and the + // non-secure payload (0x08..). Their concatenation, in that order, is + // `header || payload || signature`, so a non-secure page is never read + // through the secure alias, which would RAZ (RM0456 Table 68). The borrows + // are scoped so they drop before any self.reject call (which needs a + // mutable borrow). + let security_counter = { - Some(slice) => slice, - None => return self.reject(UpdateError::Incomplete), + let descriptor = self.flash.inactive_descriptor(); + let secure = self.flash.inactive_secure_band(); + let ns = self.flash.inactive_ns_band(); + // secure_take never exceeds the secure band. begin proved payload_len + // fits the two payload bands, so ns_take never exceeds the NS band. + let secure_take = core::cmp::min(self.payload_len, secure.len()); + let ns_take = self.payload_len - secure_take; + let header = descriptor.get(..HEADER_LEN); + let sig = descriptor.get(HEADER_LEN..HEADER_LEN + SIG_LEN); + match (header, secure.get(..secure_take), ns.get(..ns_take), sig) + { + (Some(header_seg), Some(secure_seg), Some(ns_seg), Some(sig_seg)) => + { + let segments: [&[u8]; 4] = + [header_seg, secure_seg, ns_seg, sig_seg]; + match verify_image(&segments, self.root_key) + { + Ok(verified) => Ok(verified.security_counter()), + Err(error) => Err(UpdateError::VerifyFailed(error)), + } + } + _ => Err(UpdateError::Incomplete), + } }; - let security_counter = match verify_image(image, self.root_key) + let security_counter = match security_counter { - Ok(verified) => verified.security_counter(), - Err(error) => return self.reject(UpdateError::VerifyFailed(error)), + Ok(value) => value, + Err(error) => return self.reject(error), }; // Gate 1: reject when the image counter is below the stored NVCNT. Equal // is accepted (a re-install of the same image), so it does not waste the - // finite NVCNT burn budget on the SAME counter. + // finite NVCNT burn budget on the same counter. if security_counter < nvcnt { return self.reject(UpdateError::Rollback); @@ -528,9 +688,9 @@ where /// Arms the swap commit (RM0456 sec 7.5.8), modelled through the seam. /// /// Reads the target bank, writes [`PendingFlag::Armed`] with that bank id - /// FIRST (the commit resets the part on real hardware, so the confirm-owed + /// first (the commit resets the part on real hardware, so the confirm-owed /// marker must already be persisted), then arms the swap. On a swap-arm - /// failure the machine clears the record and stays fail-closed, OLD bank + /// failure the machine clears the record and stays fail-closed, old bank /// still bootable. /// /// # Errors @@ -555,7 +715,7 @@ where } Err(error) => { - // Undo the record so the OLD bank stays the confirmed bank. The + // Undo the record so the old bank stays the confirmed bank. The // swap was not armed, so the machine arms no reverse swap. let _ = self.flash.pending_write(PendingFlag::None); self.state = UpdateState::PendingCommit; @@ -567,18 +727,18 @@ where /// Detects the first boot of the new bank and proves the swap took effect. /// /// Run at boot. Reads the pending record. On [`PendingFlag::Armed`] it - /// compares the running bank against the armed target. A MATCH proves the + /// compares the running bank against the armed target. A match proves the /// swap committed: the machine enters [`UpdateState::AwaitingConfirm`] and - /// raises the boot count. A MISMATCH means the swap never took effect (a + /// raises the boot count. A mismatch means the swap never took effect (a /// power loss before the option load committed, RM0456 sec 7.5.8): the - /// machine clears the record and stays [`UpdateState::Idle`] on the OLD bank, - /// arming NO reverse swap. A clear record means the running bank is already + /// machine clears the record and stays [`UpdateState::Idle`] on the old bank, + /// arming no reverse swap. A clear record means the running bank is already /// confirmed. /// /// # Errors /// /// [`UpdateError::Flash`] if the record read, the bank read, or the boot - /// count fails. A read fault keeps the OLD bank path. + /// count fails. A read fault keeps the old bank path. pub fn on_boot(&mut self) -> Result { match self.flash.pending_read()? @@ -595,8 +755,8 @@ where } else { - // The swap never took effect. The OLD bank booted. Clear the - // record so no later revert flips INTO the unverified bank. + // The swap never took effect. The old bank booted. Clear the + // record so no later revert flips into the unverified bank. self.flash.pending_write(PendingFlag::None)?; self.state = UpdateState::Idle; Ok(self.state) @@ -613,16 +773,16 @@ where /// Confirms the new bank: spends the SE counter, clears the record, bumps /// NVCNT. /// - /// Run after the new bank passes its health checks AND reaches + /// Run after the new bank passes its health checks and reaches /// [`CONFIRM_BOOTS`]. It spends Gate 2 (the SE down-counter), clears the - /// pending record, then bumps Gate 1 (UM2851 NVCNT) LAST, at the terminal + /// pending record, then bumps Gate 1 (UM2851 NVCNT) last, at the terminal /// [`UpdateState::Confirmed`] transition past every revert decision. Once any /// confirm step runs the state leaves [`UpdateState::AwaitingConfirm`], so /// [`Self::revert`] can no longer fire: the machine cannot both bump NVCNT and /// later revert. /// - /// Bumping NVCNT to the SAME counter is a no-op against the monotone store, - /// so confirming the SAME image does not waste the finite burn budget. + /// Bumping NVCNT to the same counter is a no-op against the monotone store, + /// so confirming the same image does not waste the finite burn budget. /// /// # Arguments /// @@ -654,21 +814,21 @@ where self.state = UpdateState::Confirming; self.se_counter.update()?; self.flash.pending_write(PendingFlag::None)?; - // NVCNT bumps LAST, past the revert decision point. Equal-counter bumps + // NVCNT bumps last, past the revert decision point. Equal-counter bumps // are a no-op, so a re-confirm of the same image spends no burn budget. self.flash.nvcnt_bump(security_counter)?; self.state = UpdateState::Confirmed; Ok(()) } - /// Reverts the swap to the OLD bank after a confirmation timeout. + /// Reverts the swap to the old bank after a confirmation timeout. /// - /// Reachable ONLY from [`UpdateState::AwaitingConfirm`], which on_boot enters - /// ONLY after proving the forward swap took effect. Once [`Self::confirm`] + /// Reachable only from [`UpdateState::AwaitingConfirm`], which on_boot enters + /// only after proving the forward swap took effect. Once [`Self::confirm`] /// begins, the state leaves AwaitingConfirm, so a revert after a confirm step /// is refused with [`UpdateError::BadState`]. Arms the reverse swap (RM0456 /// sec 7.5.8, same atomicity), then clears the record, so the next boot - /// returns to the OLD bank with no confirm owed. + /// returns to the old bank with no confirm owed. /// /// # Errors /// @@ -692,7 +852,7 @@ where /// /// A best-effort erase: even if the cleanup erase faults, the machine still /// lands in [`UpdateState::Rejected`] then [`UpdateState::Idle`], because no - /// swap was armed and the OLD bank is bootable regardless. + /// swap was armed and the old bank is bootable regardless. fn reject(&mut self, cause: UpdateError) -> Result<(), UpdateError> { self.state = UpdateState::Rejected; @@ -707,6 +867,9 @@ where let _ = self.flash.erase_inactive(); self.written = 0; self.total_len = 0; + self.payload_len = 0; + self.header_buf = [0xFF; HEADER_LEN]; + self.sig_buf = [0xFF; SIG_LEN]; self.page_buf = [0xFF; PAGE_LEN]; } } diff --git a/crates/fw-update/src/mock.rs b/crates/fw-update/src/mock.rs index 42434e0..fe8ee74 100644 --- a/crates/fw-update/src/mock.rs +++ b/crates/fw-update/src/mock.rs @@ -3,7 +3,7 @@ //! [`MockFlash`] models the inactive bank as an in-RAM byte array plus the //! persistent counters, records, and the bank-select state. [`MockSeCounter`] //! models the secure-element down-counter. Both expose fault-injection switches -//! so a test can prove every seam error collapses to a state that keeps the OLD +//! so a test can prove every seam error collapses to a state that keeps the old //! bank bootable. //! //! Compiled only for host tests and the fuzz harness. Production never links @@ -23,12 +23,31 @@ use crate::seam::UpdateOutcome; /// The modelled page size in bytes. pub const PAGE_LEN: usize = 256; -/// The modelled inactive-bank size in bytes. +/// The modelled inactive-bank payload size in bytes. /// -/// Sized to hold a representative update image in host tests. The real bank +/// Sized to hold a representative update payload in host tests. The real bank /// geometry comes from the hardware-gated flash driver, not this mock. pub const BANK_LEN: usize = 4096; +/// The modelled secure payload sub-band length in bytes (a multiple of +/// [`PAGE_LEN`]). +/// +/// The payload spans a SECWM boundary. This mock stores one contiguous payload +/// store and exposes the first [`SECURE_BAND_LEN`] bytes as the secure sub-band +/// and the rest as the non-secure sub-band, so their logical concatenation is +/// exactly the bytes `write_inactive_page` produced. The real per-page alias and +/// RAZ semantics live in the hardware-gated flash driver's controller model, not +/// this abstract page-index mock, so this mock proves the machine plumbing, not +/// the alias behaviour. +pub const SECURE_BAND_LEN: usize = 2048; + +/// The modelled descriptor store length in bytes. +/// +/// The descriptor holds the 24-byte header and the 64-byte signature (88 bytes). +/// The store is a little larger, the rest staying erased, matching the real +/// descriptor page whose tail stays erased. +pub const DESCRIPTOR_LEN: usize = 128; + /// Where a seam call should be forced to fail, for fail-closed tests. /// /// `None` means the mock behaves normally. Any other variant makes the matching @@ -58,11 +77,12 @@ pub enum FaultPoint /// A host model of the inactive bank and the persistent update records. /// /// `committed` and `reverted` record whether the matching seam call was issued, -/// so a test can assert the OLD bank stays bootable on any failure path. The -/// mock writes NO real flash and arms NO real SWAP_BANK. +/// so a test can assert the old bank stays bootable on any failure path. The +/// mock writes no real flash and arms no real SWAP_BANK. pub struct MockFlash { bank: [u8; BANK_LEN], + descriptor: [u8; DESCRIPTOR_LEN], nvcnt: u32, pending: PendingFlag, boot_count: u32, @@ -78,13 +98,14 @@ impl MockFlash { /// Builds an erased bank with the given starting flash counter. /// - /// Models the OLD bank as [`BankId::Bank1`] running and [`BankId::Bank2`] as + /// Models the old bank as [`BankId::Bank1`] running and [`BankId::Bank2`] as /// the inactive target the swap would make bootable. pub fn new(nvcnt: u32) -> MockFlash { MockFlash { bank: [0xFF; BANK_LEN], + descriptor: [0xFF; DESCRIPTOR_LEN], nvcnt, pending: PendingFlag::None, boot_count: 0, @@ -127,12 +148,18 @@ impl MockFlash self.reverted } - /// Reads back the modelled bank contents (test inspection only). + /// Reads back the modelled payload bank contents (test inspection only). pub fn bank(&self) -> &[u8] { &self.bank } + /// Reads back the modelled descriptor contents (test inspection only). + pub fn descriptor(&self) -> &[u8] + { + &self.descriptor + } + /// The stored flash anti-rollback counter (test inspection only). pub fn nvcnt(&self) -> u32 { @@ -153,9 +180,31 @@ impl MockFlash impl FlashSeam for MockFlash { - fn inactive_bank(&self) -> &[u8] + fn inactive_descriptor(&self) -> &[u8] { - &self.bank + // The descriptor store: header at [0:24], signature at [24:88]. On silicon + // this is page 9 read through the secure alias. + &self.descriptor + } + + fn inactive_secure_band(&self) -> &[u8] + { + // The first SECURE_BAND_LEN bytes of the contiguous payload store. On + // silicon this is the secure payload sub-band read through the secure + // alias. + self.bank + .get(..SECURE_BAND_LEN) + .unwrap_or(&self.bank) + } + + fn inactive_ns_band(&self) -> &[u8] + { + // The remainder of the payload store. On silicon this is the non-secure + // payload sub-band read through the non-secure alias. Concatenated after + // the secure band it reproduces the whole written payload. + self.bank + .get(SECURE_BAND_LEN..) + .unwrap_or(&[]) } fn erase_inactive(&mut self) -> Result<(), FlashError> @@ -165,6 +214,7 @@ impl FlashSeam for MockFlash return Err(FlashError::Hardware); } self.bank = [0xFF; BANK_LEN]; + self.descriptor = [0xFF; DESCRIPTOR_LEN]; Ok(()) } @@ -198,6 +248,20 @@ impl FlashSeam for MockFlash Ok(()) } + fn write_descriptor(&mut self, descriptor: &[u8]) -> Result<(), FlashError> + { + if self.take_fault(FaultPoint::WritePage) + { + return Err(FlashError::WriteFailed); + } + let slot = self + .descriptor + .get_mut(..descriptor.len()) + .ok_or(FlashError::OutOfRange)?; + slot.copy_from_slice(descriptor); + Ok(()) + } + fn running_bank(&mut self) -> Result { Ok(self.running) diff --git a/crates/fw-update/src/power_fault.rs b/crates/fw-update/src/power_fault.rs deleted file mode 100644 index c2a1392..0000000 --- a/crates/fw-update/src/power_fault.rs +++ /dev/null @@ -1,869 +0,0 @@ -//! Machine-checked power-fault harness over the dual-bank update machine. -//! -//! The earlier tests trace the power-loss windows by hand. This harness turns -//! that tracing into a machine-checked property. It drives the full machine begin -//! -> receive -> verify_and_accept -> commit -> [modelled reset] -> on_boot -> -//! confirm or revert, and injects a power loss at EVERY persistent-mutation -//! boundary of the WHOLE flow, before and after the mutation, plus a torn-write -//! variant. A SINGLE GLOBAL cut index walks every persistent mutation across the -//! reset boundary, so a cut can fire in confirm or revert AFTER the reboot, not -//! only before it. After each injected cut the harness rebuilds the [`Updater`] -//! from the surviving persistent state (a modelled reboot), runs on_boot -//! recovery, retries reboots until the state settles, then asserts the safety -//! invariant on that settled state. -//! -//! # The cut spans the reset -//! -//! The remaining cut countdown rides inside [`PersistentState`] (see -//! [`crate::fidelity`]), so the post-reset model re-arms it. That places the -//! confirm mutations (the SE spend, the pending clear, the NVCNT bump done LAST) -//! and the revert mutations (the reverse-swap arm, the pending clear) inside the -//! fault-injection span. -//! -//! # The fidelity model closes the gap -//! -//! The cuts run against [`FidelityFlash`], which models program-clears-bits-only, -//! two physically separate bank stores, a staged SWAP_BANK applied only at the -//! next reset, and a torn quad-word that reads back as detectable corruption. -//! Those are invisible behind the simple [`crate::mock::MockFlash`], so this -//! harness is the only place the silicon-only faults are representable. -//! -//! # No new attacker-facing decoder -//! -//! This 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. - -use ed25519_dalek::Signer; -use ed25519_dalek::SigningKey; -use image_verify::RootKey; -use image_verify::verify_image; - -use crate::DEV_ROOT_KEY; -use crate::SE_COUNTER_ORIGIN; -use crate::UpdateState; -use crate::Updater; -use crate::fidelity::CutMode; -use crate::fidelity::CutOutcome; -use crate::fidelity::FidelityFlash; -use crate::fidelity::FidelitySeCounter; -use crate::fidelity::PersistentState; -use crate::seam::BankId; -use crate::seam::PendingFlag; - -// The signing seed whose public key equals DEV_ROOT_KEY (the all-0x01 scalar). -const DEV_SEED: [u8; 32] = [1u8; 32]; - -// Pinned header layout (image-verify format, HEADER_LEN = 24, SIG_LEN = 64). -const HEADER_LEN: usize = 24; -const OFF_MAGIC: usize = 0; -const OFF_FORMAT_VERSION: usize = 4; -const OFF_ALGORITHM: usize = 5; -const OFF_VERSION_MAJOR: usize = 6; -const OFF_SECURITY_COUNTER: usize = 14; -const OFF_PAYLOAD_LEN: usize = 18; -const MAGIC: [u8; 4] = *b"PKIM"; -const FORMAT_VERSION: u8 = 1; -const ALG_ED25519: u8 = 0x01; - -// The image security counter the baseline OLD bank and the new image carry. The -// stored NVCNT starts below it so the update is a forward step, not a downgrade. -const OLD_BANK_COUNTER: u32 = 4; -const NEW_IMAGE_COUNTER: u32 = 5; -const BASELINE_NVCNT: u32 = 4; - -// An SE counter value whose derived anti-rollback floor is at or below the image -// counter, so Gate 2 accepts the forward step. -const SE_AT_FLOOR: u32 = SE_COUNTER_ORIGIN - NEW_IMAGE_COUNTER; - -// Builds a HEADER || payload || signature image signed with the dev seed. -fn build_image(security_counter: u32, payload: &[u8]) -> std::vec::Vec -{ - let mut header = [0u8; HEADER_LEN]; - header[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC); - header[OFF_FORMAT_VERSION] = FORMAT_VERSION; - header[OFF_ALGORITHM] = ALG_ED25519; - header[OFF_VERSION_MAJOR] = 1; - header[OFF_SECURITY_COUNTER..OFF_SECURITY_COUNTER + 4] - .copy_from_slice(&security_counter.to_le_bytes()); - header[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4] - .copy_from_slice(&(payload.len() as u32).to_le_bytes()); - - let mut signed = std::vec::Vec::new(); - signed.extend_from_slice(&header); - signed.extend_from_slice(payload); - - let sk = SigningKey::from_bytes(&DEV_SEED); - let sig = sk.sign(&signed); - - let mut image = signed; - image.extend_from_slice(&sig.to_bytes()); - image -} - -// Builds an image whose signature is corrupted, so verify_image rejects it. -fn build_rejected_image(payload: &[u8]) -> std::vec::Vec -{ - let mut image = build_image(NEW_IMAGE_COUNTER, payload); - // Flip a byte inside the trailing signature so the Ed25519 check fails. The - // header still parses, so the image reaches the signature check and is - // rejected there, never accepted. - if let Some(last) = image.last_mut() - { - *last ^= 0xFF; - } - image -} - -fn dev_root() -> RootKey -{ - match RootKey::from_bytes(DEV_ROOT_KEY) - { - Ok(key) => key, - Err(_) => panic!("dev root key is on-curve"), - } -} - -// A baseline persistent state: the OLD bank store (bank_a, BankId::Bank1) holds a -// valid signed image, the inactive store (bank_b, BankId::Bank2) is erased. The -// update flow writes only the inactive store, so the OLD store is provably -// untouched until a swap is confirmed. The returned image is the exact bytes the -// OLD bank store holds, so the OLD-bank-bootable invariant verifies the MODEL's -// own OLD-bank bytes, not a freshly rebuilt copy. -fn baseline() -> (PersistentState, std::vec::Vec) -{ - let old_image = build_image(OLD_BANK_COUNTER, b"old firmware payload here"); - let mut state = PersistentState::baseline(BASELINE_NVCNT); - // Place the OLD image into the running (OLD) store. The harness reads it back - // out of the model to verify the OLD bank, never from a rebuilt copy. - let store = &mut state.bank_a; - let len = core::cmp::min(old_image.len(), store.len()); - store[..len].copy_from_slice(&old_image[..len]); - (state, old_image) -} - -// Which recovery branch the post-reset boot drives once the swap took effect. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Health -{ - // The new bank is healthy, so on_boot reaches AwaitingConfirm and confirm is - // driven (machine.rs confirm: SE spend, pending clear, NVCNT bump LAST). - Confirm, - // The new bank failed its health check, so confirm is skipped and revert is - // driven instead (machine.rs revert: reverse-swap arm, pending clear). - Revert, -} - -// The settled result of driving a flow under one armed cut. -struct FlowResult -{ - surviving: PersistentState, - outcome: CutOutcome, - // The global mutation index the cut fired at, if it fired anywhere in the - // whole flow (across the reset). - fired_index: Option, - // The end-to-end disposition of the swap once the state settled. - settled: Settled, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Settled -{ - // The OLD bank still boots (the swap was never confirmed). - OldBank, - // The NEW bank booted and the swap was confirmed end to end. - Confirmed, -} - -// Reboots repeatedly from `surviving` until the state settles, driving on_boot -// recovery and then confirm or revert by `health` on each boot cycle. A cut that -// survived into the post-reset segment fires inside one of these boots and faults -// the recovery mid-way. The next boot runs on clean silicon (the cut is spent) -// and retries, so the loop runs until the state reaches a fixed point with no -// staged swap and no record. Sets `*fired_anywhere` if a cut fires in any boot, -// and returns the settled state. -fn drive_recovery -( - root: &RootKey, - mut surviving: PersistentState, - health: Health, - fired_anywhere: &mut bool, -) - -> PersistentState -{ - // Each loop iteration models one boot cycle: a reset (which applies any staged - // swap), then on_boot, then confirm or revert by health. - let mut guard = 0u32; - loop - { - guard += 1; - assert!(guard < 16, "recovery must settle in a bounded number of boots"); - - // Each boot after the first applies the staged option load atomically: - // a reboot IS a reset. The first iteration already had `reset_applied` - // resolved by the caller. - if guard > 1 - { - surviving.apply_reset(); - } - - let before = surviving; - let flash2 = FidelityFlash::new(surviving); - let se2 = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up2 = Updater::new(root, flash2, se2); - - let boot_state = up2.on_boot(); - - if let Ok(UpdateState::AwaitingConfirm) = boot_state - { - match health - { - Health::Confirm => - { - let _ = up2.confirm(NEW_IMAGE_COUNTER); - } - Health::Revert => - { - let _ = up2.revert(); - } - } - } - if up2.flash().outcome() == CutOutcome::Fired - { - *fired_anywhere = true; - } - - surviving = up2.into_flash().into_surviving(); - - // The state has settled once no swap is staged, no record dangles, and a - // full clean boot cycle changed nothing (a functional fixed point). A - // cut countdown may still ride if its armed index is past the LAST - // mutation the settled flow ever issues: that cut is genuinely - // unreachable for this configuration (recorded as NotReached), so the - // fixed point still settles. The leftover countdown is cleared by the - // caller. - let quiescent = surviving.staged_swap.is_none() - && surviving.pending == PendingFlag::None; - if quiescent && surviving == before - { - break; - } - } - surviving -} - -// Drives the WHOLE flow under a single global cut at `cut_index` in `mode`. -// -// Segment 1 runs begin -> receive -> accept -> commit with the cut armed. The cut -// countdown that survives rides in the persistent state. `reset_applied` models -// the option-load-at-reset window: `true` applies the staged swap atomically -// (RM0456 sec 7.5.8), `false` models a cut before the option load committed, which -// keeps the OLD bank running. After the reset the harness reboots repeatedly, -// running on_boot recovery and driving confirm or revert by `health`, until the -// state settles (no cut left to fire and no further mutation owed). -fn run_flow -( - root: &RootKey, - image: &[u8], - cut_index: u32, - mode: CutMode, - reset_applied: bool, - health: Health, -) - -> FlowResult -{ - let (state, _old) = baseline(); - let mut flash = FidelityFlash::new(state); - flash.arm_cut(cut_index, mode); - let se = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up = Updater::new(root, flash, se); - - // Stream and accept. Any error here is the cut firing during erase, a page - // write, or a read, which collapses the machine fail-closed. - let accepted = up.begin(image.len()).is_ok() - && up.receive_chunk(0, image).is_ok() - && up.verify_and_accept().is_ok(); - - // Commit the swap if accepted. A cut here leaves the staged swap either set - // or not, which the modelled reset resolves. - let _committed = accepted && up.commit().is_ok(); - - // The global index the cut fires at IS the armed `cut_index`, because the cut - // fires exactly when the countdown reaches the armed op (in either segment). - // The harness records whether it fired anywhere across the whole flow. - let mut fired_anywhere = up.flash().outcome() == CutOutcome::Fired; - - // Model the reboot. Read the surviving state out (carrying any unspent cut - // countdown). The option-load-at-reset window has two outcomes (RM0456 sec - // 7.5.8). When `reset_applied` is true the option program landed, so the - // staged swap applies atomically and the NEW bank boots. When it is false the - // cut killed the option program before it landed, so the arm is LOST and the - // OLD option bytes (the OLD bank) survive: the staged swap never takes effect - // and is dropped. Both outcomes must hold the invariant. - let mut surviving = up.into_flash().into_surviving(); - if reset_applied - { - surviving.apply_reset(); - } - else - { - // The option program did not complete: the arm is lost, OLD bank boots. - surviving.staged_swap = None; - } - - // Drive recovery to a settled state across repeated boot cycles. A cut that - // survived into the post-reset segment fires inside one of these boots, and - // the loop retries on clean silicon until the state reaches a fixed point. - surviving = drive_recovery(root, surviving, health, &mut fired_anywhere); - - // Clear any unreachable leftover cut so the settled state is clean. The - // outcome already records NotReached for a cut that never hit a mutation. - surviving.cut_countdown = None; - - let settled = settled_disposition(&surviving, health); - let (outcome, fired_index) = if fired_anywhere - { - (CutOutcome::Fired, Some(cut_index)) - } - else - { - (CutOutcome::NotReached, None) - }; - FlowResult - { - surviving, - outcome, - fired_index, - settled, - } -} - -// Decides the settled disposition from the final state and the health branch. -fn settled_disposition(state: &PersistentState, health: Health) -> Settled -{ - // A confirmed swap leaves the NEW bank (Bank2) running with the record clear. - // Any other settled state keeps the OLD bank (Bank1) running. - if health == Health::Confirm - && state.running == BankId::Bank2 - && state.pending == PendingFlag::None - { - Settled::Confirmed - } - else - { - Settled::OldBank - } -} - -// Asserts the safety invariant on a settled state, given the OLD bank image and -// the end-to-end disposition. -// -// (a) the OLD bank stays bootable at every cut until a swap is confirmed, -// (b) the booting bank always verifies and an unverified image never boots, -// (c) the NVCNT never rises above the security counter of the bank that boots, -// (d) no settled state leaves a staged swap pointing at the unverified bank. -fn assert_invariants -( - result: &FlowResult, - old_image: &[u8], - new_image: &[u8], - root: &RootKey, -) -{ - let surviving = &result.surviving; - - // (d) After recovery no swap may be left staged and no pending record may - // dangle toward the unverified bank. - assert_eq!( - surviving.staged_swap, - None, - "no staged swap may survive a settled recovery" - ); - assert_eq!( - surviving.pending, - PendingFlag::None, - "no pending record may survive a settled recovery" - ); - - match result.settled - { - Settled::Confirmed => - { - // The swap is confirmed: the NEW bank (Bank2) is the bank that boots. - assert_eq!( - surviving.running, - BankId::Bank2, - "a confirmed swap boots the NEW bank" - ); - // (b) The booting bank must verify. Read the EXACT bytes that ended - // up bootable out of the model and prove they verify. - let booted = surviving.store(BankId::Bank2); - let len = core::cmp::min(new_image.len(), booted.len()); - assert!( - verify_image(&booted[..len], root).is_ok(), - "the confirmed NEW bank bytes must verify" - ); - // (c) NVCNT never above the booting bank counter. - assert!( - surviving.nvcnt <= NEW_IMAGE_COUNTER, - "NVCNT must not exceed the booting bank counter" - ); - } - Settled::OldBank => - { - // (a) The swap is not confirmed, so the OLD bank (Bank1) must boot. - assert_eq!( - surviving.running, - BankId::Bank1, - "an unconfirmed swap must keep the OLD bank running" - ); - // (b) The OLD bank bytes in the MODEL must still verify (non-vacuous: - // these are the actual stored bytes, never a rebuilt copy). - let booted = surviving.store(BankId::Bank1); - let len = core::cmp::min(old_image.len(), booted.len()); - assert!( - verify_image(&booted[..len], root).is_ok(), - "the OLD bank bytes must still verify" - ); - // (c) No Gate-1 poisoning: an unconfirmed update must not raise NVCNT - // above the OLD bank counter. - assert!( - surviving.nvcnt <= OLD_BANK_COUNTER, - "NVCNT must not rise above the OLD bank on an unconfirmed update" - ); - } - } -} - -// The number of global mutation indices the valid-image confirm flow walks. -// -// Derived from the 600-byte payload image (688 bytes, two full pages plus a -// partial). The sequence is: erase (0), page write (1), page write (2), partial -// page write (3), pending arm (4), swap arm (5), [reset], boot_count_advance (6), -// confirm pending clear (7), confirm NVCNT bump (8). The revert flow walks the -// same count: ..., boot_count_advance (6), reverse-swap arm (7), pending clear -// (8). The harness enumerates 0..MUTATION_COUNT and asserts every index that is -// reachable for a configuration fires at least once. -const MUTATION_COUNT: u32 = 9; - -// The fault-injection axes the exhaustive census walks. Every cut mode, both -// option-load-at-reset outcomes (the reset committing the staged swap and a cut -// before it committed, RM0456 sec 7.5.8), and both health branches (confirm and -// revert, BLOCKER 2). All three must hold the invariant. -const CUT_MODES: [CutMode; 3] = -[ - CutMode::BeforeMutation, - CutMode::AfterMutation, - CutMode::TornWrite, -]; -const RESET_OUTCOMES: [bool; 2] = [true, false]; -const HEALTHS: [Health; 2] = [Health::Confirm, Health::Revert]; - -// Walks every global cut index for ONE fixed (mode, reset outcome, health) -// configuration, asserting the safety invariant on each settled state. Returns -// how many of that config's cuts actually hit a reachable mutation, plus the -// per-index census of which global mutation indices fired within this config. -fn census_one_config -( - root: &RootKey, - image: &[u8], - old_image: &[u8], - mode: CutMode, - reset_applied: bool, - health: Health, -) - -> (u32, [bool; MUTATION_COUNT as usize]) -{ - let mut fired = 0u32; - let mut seen = [false; MUTATION_COUNT as usize]; - for k in 0..MUTATION_COUNT - { - let result = run_flow(root, image, k, mode, reset_applied, health); - assert_invariants(&result, old_image, image, root); - - if result.outcome == CutOutcome::Fired - { - fired += 1; - } - if let Some(idx) = result.fired_index - && let Some(slot) = seen.get_mut(idx as usize) - { - *slot = true; - } - } - (fired, seen) -} - -// Drives the full cross product of cut mode, reset outcome, health branch, and -// global cut index over one image, asserting the safety invariant on every -// settled state. Returns the interleaving total, the count of cuts that actually -// fired, and the per-index census of which global mutation indices ever fired. -// The census would have caught the earlier gap where no cut fired after the -// reset. -fn drive_full_census -( - root: &RootKey, - image: &[u8], - old_image: &[u8], -) - -> (u32, u32, [bool; MUTATION_COUNT as usize]) -{ - let mut total = 0u32; - let mut fired = 0u32; - let mut fired_seen = [false; MUTATION_COUNT as usize]; - - for mode in CUT_MODES - { - for reset_applied in RESET_OUTCOMES - { - for health in HEALTHS - { - let (config_fired, config_seen) = - census_one_config(root, image, old_image, mode, reset_applied, health); - // One index walked per k, so the config adds MUTATION_COUNT to the - // total. Merge its fired count and OR its census into the running - // accumulators. - total += MUTATION_COUNT; - fired += config_fired; - for (slot, hit) in fired_seen.iter_mut().zip(config_seen.iter()) - { - *slot |= *hit; - } - } - } - } - (total, fired, fired_seen) -} - -#[test] -fn exhaustive_power_fault_interleavings_hold_the_invariant() -{ - let root = dev_root(); - // A small multi-page image so the script issues several page writes, each a - // distinct cut boundary. The trailing partial page adds one more write. - let image = build_image(NEW_IMAGE_COUNTER, &[0xCD; 600]); - let (_state, old_image) = baseline(); - - let (total, fired, fired_seen) = - drive_full_census(&root, &image, &old_image); - - // Every global mutation index must have fired at least once across the - // census. This is the assertion that proves the cut spans the WHOLE flow, - // including the post-reset confirm and revert mutations. - for (idx, seen) in fired_seen.iter().enumerate() - { - assert!( - *seen, - "global mutation index {idx} never fired, the cut span has a gap" - ); - } - - // Report the interleaving count honestly. `total` is the full cross product - // of modes, reset outcomes, health branches, and cut indices. `fired` counts - // configurations where the armed cut actually hit a reachable mutation. Some - // configurations do not reach a given index (for example a pre-reset cut - // makes the post-reset branch shorter), so `fired` is below `total` by - // design, and the per-index census above is the real coverage proof. - let configs = (CUT_MODES.len() * RESET_OUTCOMES.len() * HEALTHS.len()) as u32; - std::eprintln!( - "power-fault harness: {total} interleavings exercised, \ - {fired} cuts fired, every one of {MUTATION_COUNT} global mutation \ - indices fired at least once" - ); - assert_eq!(total, configs * MUTATION_COUNT); - assert!(fired > 0, "at least one cut must have fired"); -} - -#[test] -fn rejected_image_never_commits_at_any_cut() -{ - // BLOCKER 3 part 2: stream an image with a bad signature, drive the full flow - // with a cut at every index in every mode, and assert it NEVER reaches a - // confirmed swap and the OLD bank always boots. A rejected image must never - // arm a swap nor flip the running bank. - let root = dev_root(); - let bad_image = build_rejected_image(&[0xCD; 600]); - let (_state, old_image) = baseline(); - - let modes = [ - CutMode::BeforeMutation, - CutMode::AfterMutation, - CutMode::TornWrite, - ]; - let reset_outcomes = [true, false]; - - for mode in modes - { - for reset_applied in reset_outcomes - { - for k in 0..MUTATION_COUNT - { - let result = run_flow( - &root, - &bad_image, - k, - mode, - reset_applied, - Health::Confirm, - ); - // The image never verifies, so the swap is never confirmed. - assert_eq!( - result.settled, - Settled::OldBank, - "a rejected image must never reach a confirmed swap" - ); - // The OLD bank still boots and still verifies. - assert_eq!( - result.surviving.running, - BankId::Bank1, - "a rejected image must keep the OLD bank running" - ); - let booted = result.surviving.store(BankId::Bank1); - let len = core::cmp::min(old_image.len(), booted.len()); - assert!( - verify_image(&booted[..len], &root).is_ok(), - "the OLD bank bytes must still verify after a rejection" - ); - // No swap may be staged and no record may dangle. - assert_eq!(result.surviving.staged_swap, None); - assert_eq!(result.surviving.pending, PendingFlag::None); - // NVCNT must not have risen on a rejected image. - assert!( - result.surviving.nvcnt <= OLD_BANK_COUNTER, - "a rejected image must not bump NVCNT" - ); - } - } - } -} - -#[test] -fn se_spend_interrupted_does_not_double_spend_or_strand() -{ - // MAJOR 2: interrupt confirm right at the SE spend (the channel drops on - // se.update()), then prove the recovery on the next boot does not - // double-spend the SE counter and does not strand a half-confirmed state. The - // machine spends the SE counter FIRST in confirm (machine.rs confirm), so a - // drop there leaves the swap committed, the record still Armed, and the NVCNT - // not yet bumped. The next boot must re-enter AwaitingConfirm and complete. - let root = dev_root(); - let image = build_image(NEW_IMAGE_COUNTER, b"se spend window payload one"); - - // Run to a clean post-reset AwaitingConfirm state. - let (state, _old) = baseline(); - let flash = FidelityFlash::new(state); - let se = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up = Updater::new(&root, flash, se); - up.begin(image.len()).expect("begin"); - up.receive_chunk(0, &image).expect("receive"); - up.verify_and_accept().expect("accept"); - up.commit().expect("commit"); - let mut surviving = up.into_flash().into_surviving(); - surviving.apply_reset(); - - // First confirm boot: arm a channel drop on the SE update. The spend faults. - let flash2 = FidelityFlash::new(surviving); - let mut se2 = FidelitySeCounter::new(SE_AT_FLOOR); - se2.arm_drop_on_update(); - let mut up2 = Updater::new(&root, flash2, se2); - assert_eq!(up2.on_boot().expect("on_boot"), UpdateState::AwaitingConfirm); - // The confirm fails at the SE spend, so the counter did not decrement. - assert!(up2.confirm(NEW_IMAGE_COUNTER).is_err()); - assert!( - !up2.se_counter().updated(), - "a dropped SE spend must not decrement the counter" - ); - // The swap is still committed, the record still Armed, NVCNT not bumped. - let after_drop = up2.into_flash().into_surviving(); - assert_eq!(after_drop.running, BankId::Bank2, "swap stays committed"); - assert!( - matches!(after_drop.pending, PendingFlag::Armed(_)), - "the confirm-owed record must survive a dropped spend" - ); - assert_eq!(after_drop.nvcnt, BASELINE_NVCNT, "NVCNT not bumped yet"); - - // Next boot, channel up: the recovery re-enters AwaitingConfirm and confirms - // cleanly. The SE counter spends exactly once (no double spend). - let flash3 = FidelityFlash::new(after_drop); - let se3 = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up3 = Updater::new(&root, flash3, se3); - assert_eq!(up3.on_boot().expect("on_boot"), UpdateState::AwaitingConfirm); - up3.confirm(NEW_IMAGE_COUNTER).expect("confirm"); - assert!(up3.se_counter().updated(), "the recovery spends the SE once"); - assert_eq!(up3.se_counter().value(), SE_AT_FLOOR - 1, "spent exactly once"); - let settled = up3.into_flash().into_surviving(); - assert_eq!(settled.running, BankId::Bank2, "NEW bank confirmed"); - assert_eq!(settled.pending, PendingFlag::None, "record cleared"); - assert_eq!(settled.nvcnt, NEW_IMAGE_COUNTER, "NVCNT bumped LAST"); -} - -#[test] -fn reset_after_clean_commit_boots_new_bank_and_confirms() -{ - // A clean run with no cut: commit stages the swap, the modelled reset applies - // it atomically, on_boot owes a confirm, confirm completes. This pins the - // staged-swap-atomic-at-reset model end to end, and proves the confirmed NEW - // bank store verifies (BLOCKER 3 part 1). - let root = dev_root(); - let image = build_image(NEW_IMAGE_COUNTER, b"clean new firmware payload"); - let (state, _old) = baseline(); - let flash = FidelityFlash::new(state); - let se = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up = Updater::new(&root, flash, se); - - up.begin(image.len()).expect("begin"); - up.receive_chunk(0, &image).expect("receive"); - up.verify_and_accept().expect("accept"); - up.commit().expect("commit"); - - // Before the reset the swap is staged, the OLD bank still runs. - assert_eq!(up.flash().persistent().running, BankId::Bank1); - assert_eq!(up.flash().persistent().staged_swap, Some(BankId::Bank2)); - - // Model the reset: the staged option load applies atomically. - let mut surviving = up.into_flash().into_surviving(); - surviving.apply_reset(); - assert_eq!(surviving.running, BankId::Bank2, "reset applied the swap"); - - let flash2 = FidelityFlash::new(surviving); - let se2 = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up2 = Updater::new(&root, flash2, se2); - assert_eq!( - up2.on_boot().expect("on_boot"), - UpdateState::AwaitingConfirm - ); - up2.confirm(NEW_IMAGE_COUNTER).expect("confirm"); - assert_eq!(up2.state(), UpdateState::Confirmed); - let settled = up2.into_flash().into_surviving(); - assert_eq!(settled.nvcnt, NEW_IMAGE_COUNTER); - // The confirmed NEW bank store must verify (the bytes that ended bootable). - let booted = settled.store(BankId::Bank2); - let len = core::cmp::min(image.len(), booted.len()); - assert!( - verify_image(&booted[..len], &root).is_ok(), - "the confirmed NEW bank bytes must verify" - ); -} - -#[test] -fn revert_returns_to_old_bank_and_leaves_no_dangling_stage() -{ - // BLOCKER 2: drive the revert branch. on_boot reaches AwaitingConfirm, then - // revert is driven (modelling the health check failing). After a revert the - // OLD bank boots, and no settled state leaves a staged swap toward the - // unverified bank with the pending still Armed. - let root = dev_root(); - let image = build_image(NEW_IMAGE_COUNTER, b"revert path firmware bytes"); - let (state, old_image) = baseline(); - let flash = FidelityFlash::new(state); - let se = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up = Updater::new(&root, flash, se); - - up.begin(image.len()).expect("begin"); - up.receive_chunk(0, &image).expect("receive"); - up.verify_and_accept().expect("accept"); - up.commit().expect("commit"); - let mut surviving = up.into_flash().into_surviving(); - surviving.apply_reset(); - assert_eq!(surviving.running, BankId::Bank2, "swap took effect"); - - // First boot of the NEW bank: on_boot owes a confirm, but the health check - // fails, so revert is driven instead. - let flash2 = FidelityFlash::new(surviving); - let se2 = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up2 = Updater::new(&root, flash2, se2); - assert_eq!(up2.on_boot().expect("on_boot"), UpdateState::AwaitingConfirm); - up2.revert().expect("revert"); - assert_eq!(up2.state(), UpdateState::Reverted); - let mut after_revert = up2.into_flash().into_surviving(); - - // The reverse swap is staged. The record is already cleared. (d) holds: no - // staged swap points at the unverified bank with pending still Armed. - assert_eq!(after_revert.pending, PendingFlag::None, "record cleared"); - assert_eq!(after_revert.staged_swap, Some(BankId::Bank1), "reverse staged"); - - // The reset applies the reverse swap atomically: the OLD bank boots again. - after_revert.apply_reset(); - assert_eq!(after_revert.running, BankId::Bank1, "OLD bank boots after revert"); - - // The OLD bank bytes still verify, and the SE counter was never spent. - let booted = after_revert.store(BankId::Bank1); - let len = core::cmp::min(old_image.len(), booted.len()); - assert!( - verify_image(&booted[..len], &root).is_ok(), - "the OLD bank bytes must still verify after a revert" - ); - assert_eq!(after_revert.nvcnt, OLD_BANK_COUNTER, "NVCNT not bumped"); - - // A boot after the revert finds no record and stays on the OLD bank. - let flash3 = FidelityFlash::new(after_revert); - let se3 = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up3 = Updater::new(&root, flash3, se3); - assert_eq!(up3.on_boot().expect("on_boot"), UpdateState::Idle); -} - -#[test] -fn cut_before_swap_reset_keeps_old_bank() -{ - // The swap is staged but the option load never commits (a cut before the - // reset). The surviving state still runs the OLD bank, and a reboot proves - // the swap never took effect, so on_boot stays on the OLD bank. - let root = dev_root(); - let image = build_image(NEW_IMAGE_COUNTER, b"new firmware payload bytes"); - let (state, old_image) = baseline(); - let flash = FidelityFlash::new(state); - let se = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up = Updater::new(&root, flash, se); - - up.begin(image.len()).expect("begin"); - up.receive_chunk(0, &image).expect("receive"); - up.verify_and_accept().expect("accept"); - up.commit().expect("commit"); - - // Model a power cut BEFORE the option load: do NOT apply the staged swap. - let surviving = up.into_flash().into_surviving(); - assert_eq!(surviving.running, BankId::Bank1, "OLD bank still runs"); - assert_eq!(surviving.staged_swap, Some(BankId::Bank2), "swap still staged"); - - let flash2 = FidelityFlash::new(surviving); - let se2 = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up2 = Updater::new(&root, flash2, se2); - // The running bank does not match the armed target, so on_boot clears the - // record and stays on the OLD bank, arming no reverse swap. - assert_eq!(up2.on_boot().expect("on_boot"), UpdateState::Idle); - assert_eq!(up2.flash().persistent().pending, PendingFlag::None); - let booted = up2.flash().persistent().store(BankId::Bank1); - let len = core::cmp::min(old_image.len(), booted.len()); - assert!(verify_image(&booted[..len], &root).is_ok()); -} - -#[test] -fn torn_page_write_makes_bank_fail_verify() -{ - // A torn quad-word during a page write poisons that quad-word, so verify - // rejects the bank and no swap is armed. The OLD bank boots, and the OLD bank - // store is never touched by the write to the inactive store. - let root = dev_root(); - // A multi-page image so a full page flushes during receive, where the tear - // lands. A sub-page image would only write at accept time. - let image = build_image(NEW_IMAGE_COUNTER, &[0xCD; 600]); - let (state, old_image) = baseline(); - let mut flash = FidelityFlash::new(state); - // Cut index 1 is the first full page write (index 0 is the erase at begin). - flash.arm_cut(1, CutMode::TornWrite); - let se = FidelitySeCounter::new(SE_AT_FLOOR); - let mut up = Updater::new(&root, flash, se); - - up.begin(image.len()).expect("begin"); - // The torn write faults the page write, collapsing the transfer fail-closed. - assert!(up.receive_chunk(0, &image).is_err()); - assert_ne!(up.state(), UpdateState::Committed); - assert_eq!(up.flash().persistent().staged_swap, None, "no swap staged"); - // The OLD bank store is untouched: the tear poisoned only the inactive store. - let booted = up.flash().persistent().store(BankId::Bank1); - let len = core::cmp::min(old_image.len(), booted.len()); - assert!( - verify_image(&booted[..len], &root).is_ok(), - "the OLD bank store must be untouched by an inactive-bank tear" - ); -} diff --git a/crates/fw-update/src/seam.rs b/crates/fw-update/src/seam.rs index f0bb6c3..07caeb4 100644 --- a/crates/fw-update/src/seam.rs +++ b/crates/fw-update/src/seam.rs @@ -1,8 +1,8 @@ //! The mockable seams the update machine drives. //! //! Every irreversible or brick-risk operation on the MCU's own firmware (a -//! flash write, an erase, a SWAP_BANK flip, an option load) is reachable ONLY -//! through an explicit method on [`FlashSeam`]. This crate ships NO real MMIO +//! flash write, an erase, a SWAP_BANK flip, an option load) is reachable only +//! through an explicit method on [`FlashSeam`]. This crate ships no real MMIO //! impl of either seam: the real volatile-flash driver is a separate //! hardware-gated crate. The only impl here is the host mock in //! [`crate::mock`]. A caller cannot emit an irreversible op except by calling a @@ -21,7 +21,7 @@ pub type PageIndex = u16; /// An error the [`FlashSeam`] returns. /// -/// Every variant collapses the machine to a state that keeps the OLD bank +/// Every variant collapses the machine to a state that keeps the old bank /// bootable (fail-closed). The machine never retries an irreversible step on /// error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -51,20 +51,20 @@ pub enum SeCounterError /// /// This record survives the reset that the swap commits on (RM0456 sec 7.5.8: /// the SWAP_BANK plus option load takes effect at the next reset). It carries -/// WHICH bank the running firmware must be, so a boot can prove the swap took +/// which bank the running firmware must be, so a boot can prove the swap took /// effect before it owes a confirm. The machine writes [`PendingFlag::Armed`] -/// BEFORE [`FlashSeam::commit_swap`], because that call triggers the reset on +/// before [`FlashSeam::commit_swap`], because that call triggers the reset on /// real hardware, so the confirm-owed marker must already be persisted when the /// new bank first runs. /// /// # Why the bank id is load-bearing /// /// A power loss after the machine writes [`PendingFlag::Armed`] but before the -/// option load commits leaves the OLD bank booting (RM0456 sec 7.5.8: the CPU +/// option load commits leaves the old bank booting (RM0456 sec 7.5.8: the CPU /// never sees a half-swapped map). On that next boot [`FlashSeam::running_bank`] -/// still reports the OLD bank, which does not match the armed target, so the +/// still reports the old bank, which does not match the armed target, so the /// machine knows the swap never took effect. It clears the record and keeps the -/// OLD bank, instead of arming a reverse swap INTO the unverified bank. +/// old bank, instead of arming a reverse swap into the unverified bank. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PendingFlag { @@ -79,7 +79,7 @@ pub enum PendingFlag /// /// The dual-bank map (RM0456 sec 7.5.8) names the two banks. The machine pairs /// the armed target with the running bank to tell "swap took effect, confirm -/// owed" apart from "swap never took effect, OLD bank still boots". +/// owed" apart from "swap never took effect, old bank still boots". #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BankId { @@ -89,15 +89,15 @@ pub enum BankId Bank2, } -/// The persistent outcome of the LAST update attempt. +/// The persistent outcome of the last update attempt. /// /// An auto-revert (the boot-stage re-arms SWAP_BANK back to the old bank when a -/// new image does not confirm in time) must NOT be silent. The boot-stage SETS +/// new image does not confirm in time) must not be silent. The boot-stage sets /// this record on an auto-revert, and it is cleared when a fresh update begins or /// a new image confirms. It survives the reset the revert commits on, so a later /// boot and a host tool can read it back and surface the event. /// -/// This crate ships the type and the seam. The boot-stage that SETS it on an +/// This crate ships the type and the seam. The boot-stage that sets it on an /// auto-revert, and the LED plus host-CLI surfacing that consumes it, are future /// work. The machine in this crate does not set it: it lives in the metadata area /// alongside the pending record, reserved for the boot-stage. @@ -119,23 +119,64 @@ pub enum UpdateOutcome /// # Safety of the commit /// /// [`Self::commit_swap`] models RM0456 sec 7.5.8: writing SWAP_BANK plus an -/// option load takes effect at the NEXT reset, atomically. The CPU never sees a -/// half-swapped map. A power loss before the option load commits leaves the OLD -/// bank booting. This crate models the commit as a seam call and emits NO real +/// option load takes effect at the next reset, atomically. The CPU never sees a +/// half-swapped map. A power loss before the option load commits leaves the old +/// bank booting. This crate models the commit as a seam call and emits no real /// SWAP_BANK or option-byte write. pub trait FlashSeam { - /// Borrows the whole inactive bank as the bytes the swap will boot. + /// Borrows the image descriptor of the inactive bank, read through the secure + /// alias. + /// + /// The signed image file stays contiguous `header || payload || signature`, + /// but the device de-interleaves it: the header lands at the front of the + /// descriptor and the signature just after it, while the payload lands + /// page-aligned at its link origin. So the descriptor holds the header at + /// byte offset [0:`image_verify::HEADER_LEN`] and the signature at + /// [`image_verify::HEADER_LEN`:`image_verify::HEADER_LEN`+`image_verify::SIG_LEN`]. + /// The descriptor is a secure page, read through the secure alias, the store + /// the commit boots. + /// + /// # Returns + /// + /// The inactive-bank descriptor bytes, at least a header plus a signature. + fn inactive_descriptor(&self) -> &[u8]; + + /// Borrows the secure payload sub-band of the inactive bank, read through the + /// secure alias. + /// + /// The payload spans a SECWM boundary: the low payload pages are secure, the + /// high pages non-secure (RM0456 sec 7.9.17). The two sub-bands carry + /// different security attributes and must be read through different address + /// aliases: a secure page read through the non-secure alias returns RAZ, and + /// vice versa (RM0456 Table 68). So the seam hands the verifier the descriptor + /// plus two payload bands whose logical concatenation, in order header, + /// secure payload, non-secure payload, signature, is the image, each read + /// through its own alias. /// /// On real hardware the inactive bank is memory-mapped, so this returns a - /// view of the exact bytes [`Self::commit_swap`] makes bootable. The machine - /// verifies THESE bytes, so the verified image and the committed image are - /// the same bytes by construction. + /// view of the exact secure payload bytes [`Self::commit_swap`] makes + /// bootable. The machine verifies these bytes, so the verified image and the + /// committed image are the same bytes by construction. + /// + /// # Returns + /// + /// The inactive-bank secure payload sub-band, erased bytes included. + fn inactive_secure_band(&self) -> &[u8]; + + /// Borrows the non-secure payload sub-band of the inactive bank, read through + /// the non-secure alias. + /// + /// In logical order the payload is [`Self::inactive_secure_band`] followed by + /// this band. Reading this band through the secure alias would return RAZ (all + /// zeros, RM0456 Table 68), so the seam reads it through the non-secure alias. + /// The verify / commit same-store property holds: these are still the bytes + /// the commit boots, read through the correct alias. /// /// # Returns /// - /// The full inactive-bank slice, erased bytes included. - fn inactive_bank(&self) -> &[u8]; + /// The inactive-bank non-secure payload sub-band, erased bytes included. + fn inactive_ns_band(&self) -> &[u8]; /// Erases the whole inactive bank to the flash erased state. /// @@ -164,6 +205,20 @@ pub trait FlashSeam ) -> Result<(), FlashError>; + /// Writes the image descriptor of the inactive bank from `descriptor`. + /// + /// `descriptor` is the header followed by the signature, so its length is + /// `image_verify::HEADER_LEN` + `image_verify::SIG_LEN`. The machine writes it + /// once, at accept time, after the descriptor page has been erased, so the + /// single programming pass raises no reprogram fault. [`Self::inactive_descriptor`] + /// reads these exact bytes back for the verify. + /// + /// # Errors + /// + /// [`FlashError::OutOfRange`] if `descriptor` is larger than the descriptor + /// page, [`FlashError::WriteFailed`] if the write did not verify. + fn write_descriptor(&mut self, descriptor: &[u8]) -> Result<(), FlashError>; + /// Reports the bank the firmware currently runs from. /// /// The machine compares this against the armed target in [`PendingFlag`] so @@ -184,7 +239,7 @@ pub trait FlashSeam /// Commits the swap: arms SWAP_BANK plus an option load (RM0456 sec 7.5.8). /// /// The flip takes effect at the next reset, atomically. This crate models - /// it. It emits NO real SWAP_BANK or option-byte write. + /// it. It emits no real SWAP_BANK or option-byte write. /// /// # Errors /// @@ -284,11 +339,11 @@ pub trait FlashSeam /// The secure-element monotonic counter (Gate 2, anti-rollback after channel up). /// -/// This counter gates the KEY-OPS accept, NOT the boot decision. The TROPIC01 -/// MCounter counts DOWN: [`Self::update`] decrements it (a successful accepted +/// This counter gates the key-ops accept, not the boot decision. The TROPIC01 +/// MCounter counts down: [`Self::update`] decrements it (a successful accepted /// update spends one tick). The machine reads it to enforce an anti-rollback /// floor on accept, and decrements it on a confirmed update. This crate models -/// the counter abstractly and talks to NO real secure element. +/// the counter abstractly and talks to no real secure element. pub trait SeCounterSeam { /// Reads the current secure-element counter value. diff --git a/crates/fw-update/src/test_fixtures.rs b/crates/fw-update/src/test_fixtures.rs new file mode 100644 index 0000000..3261322 --- /dev/null +++ b/crates/fw-update/src/test_fixtures.rs @@ -0,0 +1,83 @@ +//! Signed-image fixtures shared by the host tests. +//! +//! The tests mint images, and two mint sites drifting apart (a stale algorithm +//! id, a stale offset) would silently weaken whichever one lagged. So the minting +//! lives here. +//! +//! The header is written by hand from pinned offsets rather than through the +//! `image-verify` encoder, on purpose: it pins the on-wire layout from outside +//! the crate that owns it, so a layout change that the encoder and the verifier +//! agree on still trips these tests. + +use image_verify::RootKey; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; +use std::vec::Vec; + +use crate::DEV_ROOT_KEY_TEST_ONLY; + +/// The dev private scalar, test only. Its public key is +/// [`crate::DEV_ROOT_KEY_TEST_ONLY`]. +/// +/// The all-`0x01` value is a valid P-256 private scalar +/// (non-zero, and far below the curve order, which starts with `0xFF`), +/// so `SigningKey::from_slice` accepts it. +/// It is `cfg(test)` only and it must never become a production key. +pub(crate) const DEV_SCALAR: [u8; 32] = [1u8; 32]; + +// Pinned header layout (image-verify format, HEADER_LEN = 24, SIG_LEN = 64). +pub(crate) const HEADER_LEN: usize = 24; +pub(crate) const OFF_MAGIC: usize = 0; +pub(crate) const OFF_FORMAT_VERSION: usize = 4; +pub(crate) const OFF_ALGORITHM: usize = 5; +pub(crate) const OFF_VERSION_MAJOR: usize = 6; +pub(crate) const OFF_SECURITY_COUNTER: usize = 14; +pub(crate) const OFF_PAYLOAD_LEN: usize = 18; +pub(crate) const MAGIC: [u8; 4] = *b"PKIM"; +pub(crate) const FORMAT_VERSION: u8 = 1; + +/// The algorithm id the verifier accepts: ECDSA P-256 over SHA-256. +pub(crate) const ALG_ECDSA_P256_SHA256: u8 = 0x02; + +/// Builds a `header || payload || signature` image signed with `scalar`. +/// +/// The signature is the 64-byte `r || s` pair, normalized to low-s, the only +/// encoding the verifier accepts. Signing goes through the RustCrypto `Signer` +/// path, so the nonce is RFC 6979 deterministic and the fixture is reproducible +/// byte for byte. +pub(crate) fn build_image +( + scalar: [u8; 32], + security_counter: u32, + payload: &[u8], +) + -> Vec +{ + let mut header = [0u8; HEADER_LEN]; + header[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC); + header[OFF_FORMAT_VERSION] = FORMAT_VERSION; + header[OFF_ALGORITHM] = ALG_ECDSA_P256_SHA256; + header[OFF_VERSION_MAJOR] = 1; + header[OFF_SECURITY_COUNTER..OFF_SECURITY_COUNTER + 4] + .copy_from_slice(&security_counter.to_le_bytes()); + header[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4] + .copy_from_slice(&(payload.len() as u32).to_le_bytes()); + + let mut signed = Vec::new(); + signed.extend_from_slice(&header); + signed.extend_from_slice(payload); + + let sk = SigningKey::from_slice(&scalar).expect("test scalar is in [1, n-1]"); + let sig: p256::ecdsa::Signature = sk.sign(&signed); + let sig = sig.normalize_s(); + + let mut image = signed; + image.extend_from_slice(&sig.to_bytes()); + image +} + +/// The dev root key, built from the pinned public constant. +pub(crate) fn dev_root() -> RootKey +{ + RootKey::from_bytes(DEV_ROOT_KEY_TEST_ONLY).expect("dev root key is on-curve") +} diff --git a/crates/fw-update/src/tests.rs b/crates/fw-update/src/tests.rs index bb0d234..ac11e3b 100644 --- a/crates/fw-update/src/tests.rs +++ b/crates/fw-update/src/tests.rs @@ -1,86 +1,58 @@ //! Host tests for the dual-bank update machine, driven through the mocks. //! -//! Every irreversible seam op (commit, revert) is asserted to fire ONLY on the -//! intended path. Every fault path is asserted to keep the OLD bank bootable +//! Every irreversible seam op (commit, revert) is asserted to fire only on the +//! intended path. Every fault path is asserted to keep the old bank bootable //! (no commit), which is the fail-closed contract. The image streams through the //! seam into the mock inactive bank, and verify reads that same bank back, so a //! test proves verify and commit act on the same bytes. use super::*; -use ed25519_dalek::Signer; -use ed25519_dalek::SigningKey; -use image_verify::RootKey; +use image_verify::HEADER_LEN; +use image_verify::SIG_LEN; use image_verify::VerifyError; -use std::vec::Vec; - -// The signing seed whose public key equals DEV_ROOT_KEY (the all-0x01 scalar). -const DEV_SEED: [u8; 32] = [1u8; 32]; - -// A seed that does NOT match DEV_ROOT_KEY, used to mint a tampered image. -const WRONG_SEED: [u8; 32] = [2u8; 32]; - -// Pinned header layout (image-verify format, HEADER_LEN = 24, SIG_LEN = 64). -const HEADER_LEN: usize = 24; -const OFF_MAGIC: usize = 0; -const OFF_FORMAT_VERSION: usize = 4; -const OFF_ALGORITHM: usize = 5; -const OFF_VERSION_MAJOR: usize = 6; -const OFF_VERSION_MINOR: usize = 7; -const OFF_VERSION_REVISION: usize = 8; -const OFF_VERSION_BUILD: usize = 10; -const OFF_SECURITY_COUNTER: usize = 14; -const OFF_PAYLOAD_LEN: usize = 18; -const MAGIC: [u8; 4] = *b"PKIM"; -const FORMAT_VERSION: u8 = 1; -const ALG_ED25519: u8 = 0x01; +use p256::ecdsa::SigningKey; + +use crate::test_fixtures::DEV_SCALAR; +use crate::test_fixtures::build_image; +use crate::test_fixtures::dev_root; + +// Asserts the machine de-interleaved the signed file onto the two stores exactly: +// the header at the front of the descriptor, the signature just after it, and the +// payload page-aligned from offset 0 in the payload store. Also proves the four +// logical segments the verifier reads back concatenate to the original file, and +// that the payload store starts with the firmware bytes, not the header magic. +fn assert_deinterleaved(flash: &MockFlash, image: &[u8]) +{ + let payload_len = image.len() - HEADER_LEN - SIG_LEN; + let header = &image[..HEADER_LEN]; + let payload = &image[HEADER_LEN..HEADER_LEN + payload_len]; + let sig = &image[image.len() - SIG_LEN..]; + + assert_eq!(&flash.descriptor()[..HEADER_LEN], header, "header in descriptor"); + assert_eq!( + &flash.descriptor()[HEADER_LEN..HEADER_LEN + SIG_LEN], + sig, + "signature in descriptor" + ); + assert_eq!(&flash.bank()[..payload_len], payload, "payload in payload store"); + + // The four-segment concatenation the verifier reads is the original file. + let mut rebuilt = std::vec::Vec::new(); + rebuilt.extend_from_slice(&flash.descriptor()[..HEADER_LEN]); + rebuilt.extend_from_slice(&flash.bank()[..payload_len]); + rebuilt.extend_from_slice(&flash.descriptor()[HEADER_LEN..HEADER_LEN + SIG_LEN]); + assert_eq!(rebuilt, image, "reassembled four segments equal the file"); +} + +// A scalar that does not match DEV_ROOT_KEY_TEST_ONLY, used to mint an image the +// pinned key must reject. +const WRONG_SCALAR: [u8; 32] = [2u8; 32]; // An SE counter value whose derived anti-rollback floor is zero, so Gate 2 does // not interfere with a test that only exercises Gate 1 or the signature. const SE_FLOOR_ZERO: u32 = SE_COUNTER_ORIGIN; -// Builds a HEADER || payload || signature image signed with `seed`, carrying the -// given security counter and payload. -fn build_image -( - seed: [u8; 32], - security_counter: u32, - payload: &[u8], -) - -> Vec -{ - let mut header = [0u8; HEADER_LEN]; - header[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC); - header[OFF_FORMAT_VERSION] = FORMAT_VERSION; - header[OFF_ALGORITHM] = ALG_ED25519; - header[OFF_VERSION_MAJOR] = 1; - header[OFF_VERSION_MINOR] = 0; - header[OFF_VERSION_REVISION..OFF_VERSION_REVISION + 2] - .copy_from_slice(&0u16.to_le_bytes()); - header[OFF_VERSION_BUILD..OFF_VERSION_BUILD + 4] - .copy_from_slice(&0u32.to_le_bytes()); - header[OFF_SECURITY_COUNTER..OFF_SECURITY_COUNTER + 4] - .copy_from_slice(&security_counter.to_le_bytes()); - header[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4] - .copy_from_slice(&(payload.len() as u32).to_le_bytes()); - - let mut signed = Vec::new(); - signed.extend_from_slice(&header); - signed.extend_from_slice(payload); - - let sk = SigningKey::from_bytes(&seed); - let sig = sk.sign(&signed); - - let mut image = signed; - image.extend_from_slice(&sig.to_bytes()); - image -} - -fn dev_root() -> RootKey -{ - RootKey::from_bytes(DEV_ROOT_KEY).expect("dev root key is on-curve") -} - // Feeds a whole image into the updater in one chunk at offset 0, declaring the // exact length so the completeness gate is satisfied. fn feed @@ -95,10 +67,11 @@ fn feed } #[test] -fn dev_seed_public_key_matches_dev_root_key() +fn dev_scalar_public_key_matches_dev_root_key() { - let sk = SigningKey::from_bytes(&DEV_SEED); - assert_eq!(sk.verifying_key().to_bytes(), DEV_ROOT_KEY); + let sk = SigningKey::from_slice(&DEV_SCALAR).expect("dev scalar in [1, n-1]"); + let point = sk.verifying_key().to_sec1_point(false); + assert_eq!(point.as_ref(), &DEV_ROOT_KEY_TEST_ONLY[..]); } #[test] @@ -109,15 +82,15 @@ fn happy_path_receive_verify_commit_boot_confirm() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 5, b"new firmware payload"); + let image = build_image(DEV_SCALAR, 5, b"new firmware payload"); feed(&mut up, &image).expect("feed"); assert_eq!(up.state(), UpdateState::ReceivingChunks); up.verify_and_accept().expect("verify"); assert_eq!(up.state(), UpdateState::PendingCommit); - // After accept the whole image, including the trailing partial page, has - // landed in the mock inactive bank through the seam. - assert_eq!(&up.flash().bank()[..image.len()], &image[..]); + // After accept the whole image has de-interleaved onto the descriptor and the + // payload store through the seam. + assert_deinterleaved(up.flash(), &image); up.commit().expect("commit"); assert_eq!(up.state(), UpdateState::Committed); @@ -138,19 +111,19 @@ fn happy_path_receive_verify_commit_boot_confirm() #[test] fn verify_reads_the_bank_the_commit_will_boot() { - // Prove verify and commit act on the SAME bytes: the bank holds the image, + // Prove verify and commit act on the same bytes: the bank holds the image, // and verify reads it straight back from the seam. let root = dev_root(); let flash = MockFlash::new(0); let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 1, b"payload across one page boundary plus"); + let image = build_image(DEV_SCALAR, 1, b"payload across one page boundary plus"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify reads the bank"); assert_eq!(up.state(), UpdateState::PendingCommit); - // Verify ran off the bank, which now holds the exact image bytes. - assert_eq!(&up.flash().bank()[..image.len()], &image[..]); + // Verify ran off the stores, which now hold the de-interleaved image bytes. + assert_deinterleaved(up.flash(), &image); } #[test] @@ -164,14 +137,15 @@ fn multi_page_image_streams_through_seam() let mut up = Updater::new(&root, flash, se); let big = vec![0xABu8; 600]; - let image = build_image(DEV_SEED, 2, &big); + let image = build_image(DEV_SCALAR, 2, &big); assert!(image.len() > PAGE_LEN); feed(&mut up, &image).expect("feed multi-page"); - // Full pages flushed during receive. The trailing partial page flushes at - // accept, after which the bank holds the exact image bytes. + // Full payload pages flushed during receive. The trailing partial payload + // page and the descriptor flush at accept, after which the stores hold the + // de-interleaved image. up.verify_and_accept().expect("verify multi-page"); assert_eq!(up.state(), UpdateState::PendingCommit); - assert_eq!(&up.flash().bank()[..image.len()], &image[..]); + assert_deinterleaved(up.flash(), &image); } #[test] @@ -184,7 +158,7 @@ fn incomplete_transfer_is_rejected_no_commit() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 1, b"a complete enough payload here ok"); + let image = build_image(DEV_SCALAR, 1, b"a complete enough payload here ok"); up.begin(image.len()).expect("begin"); let half = image.len() / 2; up.receive_chunk(0, &image[..half]).expect("prefix"); @@ -203,8 +177,8 @@ fn tampered_image_is_rejected_as_bad_signature_no_commit() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - // Signed by the WRONG key, so the signature fails under DEV_ROOT_KEY. - let image = build_image(WRONG_SEED, 5, b"evil payload"); + // Signed by the wrong key, so the signature fails under DEV_ROOT_KEY. + let image = build_image(WRONG_SCALAR, 5, b"evil payload"); feed(&mut up, &image).expect("feed"); let err = up.verify_and_accept().expect_err("must reject"); @@ -224,7 +198,7 @@ fn a_chunk_that_lands_wrong_fails_closed_no_commit() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 1, b"payload"); + let image = build_image(DEV_SCALAR, 1, b"payload"); up.begin(image.len()).expect("begin"); up.receive_chunk(0, &image[..10]).expect("chunk 0"); // Skip ahead, leaving a gap: rejected fail-closed. @@ -247,7 +221,7 @@ fn downgrade_below_nvcnt_is_rejected_no_commit() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 5, b"old firmware payload"); + let image = build_image(DEV_SCALAR, 5, b"old firmware payload"); feed(&mut up, &image).expect("feed"); let err = up.verify_and_accept().expect_err("must reject downgrade"); @@ -265,7 +239,7 @@ fn se_counter_regression_is_rejected_no_commit() let se = MockSeCounter::new(SE_COUNTER_ORIGIN - 8); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 5, b"rolled-back payload"); + let image = build_image(DEV_SCALAR, 5, b"rolled-back payload"); feed(&mut up, &image).expect("feed"); let err = up.verify_and_accept().expect_err("se floor rejects"); @@ -283,7 +257,7 @@ fn se_at_floor_accepts_equal_counter() let se = MockSeCounter::new(SE_COUNTER_ORIGIN - 5); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 5, b"at-the-floor payload"); + let image = build_image(DEV_SCALAR, 5, b"at-the-floor payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("equal to floor accepted"); assert_eq!(up.state(), UpdateState::PendingCommit); @@ -299,7 +273,7 @@ fn se_unavailable_on_accept_fails_closed() se.set_unavailable(); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 5, b"payload"); + let image = build_image(DEV_SCALAR, 5, b"payload"); feed(&mut up, &image).expect("feed"); let err = up.verify_and_accept().expect_err("se unavailable"); @@ -317,7 +291,7 @@ fn equal_counter_is_accepted() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 7, b"same version payload"); + let image = build_image(DEV_SCALAR, 7, b"same version payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("equal counter accepted"); assert_eq!(up.state(), UpdateState::PendingCommit); @@ -333,7 +307,7 @@ fn reconfirm_same_counter_does_not_waste_burn_budget() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 9, b"same counter payload"); + let image = build_image(DEV_SCALAR, 9, b"same counter payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); up.commit().expect("commit"); @@ -350,7 +324,7 @@ fn confirmation_timeout_reverts_to_old_bank() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 1, b"payload"); + let image = build_image(DEV_SCALAR, 1, b"payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); up.commit().expect("commit"); @@ -361,7 +335,7 @@ fn confirmation_timeout_reverts_to_old_bank() up.revert().expect("revert"); assert_eq!(up.state(), UpdateState::Reverted); assert!(up.flash().reverted()); - // The forward swap bumped no NVCNT, so the OLD bank is not poisoned. + // The forward swap bumped no NVCNT, so the old bank is not poisoned. assert_eq!(up.flash().nvcnt(), 0); } @@ -389,7 +363,7 @@ fn commit_swap_fault_clears_pending_no_old_bank_loss() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 1, b"payload"); + let image = build_image(DEV_SCALAR, 1, b"payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); @@ -405,8 +379,8 @@ fn commit_swap_fault_clears_pending_no_old_bank_loss() fn swap_not_effective_on_boot_does_not_revert_into_unverified_bank() { // Model a power loss after the pending record was armed but before the swap - // committed: the running bank still matches the OLD bank, not the armed - // target. on_boot must clear the record and stay on the OLD bank, NOT enter + // committed: the running bank still matches the old bank, not the armed + // target. on_boot must clear the record and stay on the old bank, not enter // AwaitingConfirm where a revert could flip into the unverified bank. let root = dev_root(); let mut flash = MockFlash::new(0); @@ -431,7 +405,7 @@ fn nvcnt_bump_fault_on_confirm_leaves_swap_committed() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 2, b"payload"); + let image = build_image(DEV_SCALAR, 2, b"payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); up.commit().expect("commit"); @@ -458,7 +432,7 @@ fn revert_after_confirm_step_is_rejected() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 2, b"payload"); + let image = build_image(DEV_SCALAR, 2, b"payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); up.commit().expect("commit"); @@ -480,7 +454,7 @@ fn se_counter_unavailable_on_confirm_fails_closed() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 2, b"payload"); + let image = build_image(DEV_SCALAR, 2, b"payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); up.commit().expect("commit"); @@ -558,16 +532,52 @@ fn chunk_past_declared_length_is_rejected() } #[test] -fn begin_with_total_len_over_bank_is_rejected() +fn begin_with_payload_over_bank_is_rejected() { let root = dev_root(); let flash = MockFlash::new(0); let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let err = up.begin(BANK_LEN + 1).expect_err("over bank"); + // The payload band is BANK_LEN bytes. A file whose payload exceeds it by one + // byte (header + signature + payload band + 1) is rejected at begin. + let over = HEADER_LEN + SIG_LEN + BANK_LEN + 1; + let err = up.begin(over).expect_err("over bank"); assert_eq!(err, UpdateError::ChunkOutOfRange); assert_eq!(up.state(), UpdateState::Idle); + // The payload band's exact capacity (header + signature + BANK_LEN) is + // accepted, proving the boundary is the payload size, not the file size. + up.begin(HEADER_LEN + SIG_LEN + BANK_LEN).expect("at capacity"); + assert_eq!(up.state(), UpdateState::ReceivingChunks); +} + +#[test] +fn committed_bank_is_bootable_shaped_and_round_trips() +{ + let root = dev_root(); + let flash = MockFlash::new(0); + let se = MockSeCounter::new(SE_FLOOR_ZERO); + let mut up = Updater::new(&root, flash, se); + + // A payload whose first 8 bytes are a plausible Cortex-M vector table (an + // initial stack pointer then a reset vector), distinct from the "PKIM" magic. + let mut payload = std::vec::Vec::new(); + payload.extend_from_slice(&0x2003_0000u32.to_le_bytes()); + payload.extend_from_slice(&0x0C01_4101u32.to_le_bytes()); + payload.extend_from_slice(b"the rest of the firmware image body"); + let image = build_image(DEV_SCALAR, 3, &payload); + + feed(&mut up, &image).expect("feed"); + up.verify_and_accept().expect("verify accepts the de-interleaved image"); + assert_eq!(up.state(), UpdateState::PendingCommit); + + // The payload store at offset 0 is the firmware vector table, not the magic. + assert_eq!(&up.flash().bank()[..4], &0x2003_0000u32.to_le_bytes()); + assert_ne!(&up.flash().bank()[..4], b"PKIM"); + // The magic lives at the front of the descriptor. + assert_eq!(&up.flash().descriptor()[..4], b"PKIM"); + // Full de-interleave round-trip: the four segments reassemble the file. + assert_deinterleaved(up.flash(), &image); } #[test] @@ -591,7 +601,7 @@ fn confirm_before_boot_floor_is_bad_state() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 1, b"payload"); + let image = build_image(DEV_SCALAR, 1, b"payload"); feed(&mut up, &image).expect("feed"); up.verify_and_accept().expect("verify"); up.commit().expect("commit"); @@ -608,7 +618,7 @@ fn multi_chunk_accumulation_tracks_written_len() let se = MockSeCounter::new(SE_FLOOR_ZERO); let mut up = Updater::new(&root, flash, se); - let image = build_image(DEV_SEED, 3, b"chunked firmware payload here"); + let image = build_image(DEV_SCALAR, 3, b"chunked firmware payload here"); up.begin(image.len()).expect("begin"); // Split the image into two in-order chunks. let mid = image.len() / 2; diff --git a/crates/image-verify/Cargo.toml b/crates/image-verify/Cargo.toml index 286f2f4..0245e76 100644 --- a/crates/image-verify/Cargo.toml +++ b/crates/image-verify/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true repository.workspace = true license.workspace = true publish = false -description = "no_std Ed25519 verifier for the patina_key signed firmware-image format." +description = "no_std ECDSA P-256 verifier for the patina_key signed firmware-image format." # Dev-only fuzz harness kept out of the published package. It has its own # detached workspace and needs nightly instrumentation. exclude = ["/fuzz"] @@ -21,11 +21,16 @@ _fuzz = [] encode = [] [dependencies] -# Ed25519 verify path only. default-features = false keeps it no_std and +# ECDSA P-256 verify path only. default-features = false keeps it no_std and # heap-free. Tests additionally use its SigningKey to mint fixtures. -ed25519-dalek = { workspace = true } +p256 = { workspace = true } -# This crate is pure parsing plus one dalek verify call. It has NO unsafe, so it +# SHA-256 streamed across the image segments. ECDSA is prehash-native, so the +# digest is computed here and handed to the verifier, which is what lets a +# non-contiguous image be verified with no copy. +sha2 = { workspace = true } + +# This crate is pure parsing plus one ECDSA verify call. It has NO unsafe, so it # INHERITS the workspace lints (unsafe_code = forbid). A local [lints] table # would DOWNGRADE forbid to deny, which is not wanted here. [lints] diff --git a/crates/image-verify/fuzz/Cargo.lock b/crates/image-verify/fuzz/Cargo.lock index 96a5fe6..2460f1d 100644 --- a/crates/image-verify/fuzz/Cargo.lock +++ b/crates/image-verify/fuzz/Cargo.lock @@ -8,13 +8,25 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -35,87 +47,132 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", + "rand_core", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", + "cmov", "subtle", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "der" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "const-oid", + "zeroize", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "ctutils", ] [[package]] -name = "ed25519" -version = "2.2.3" +name = "ecdsa" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", "signature", + "zeroize", ] [[package]] -name = "ed25519-dalek" -version = "2.2.0" +name = "elliptic-curve" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "curve25519-dalek", - "ed25519", - "sha2", + "base16ct", + "crypto-bigint", + "crypto-common", + "digest", + "ff", + "group", + "hybrid-array", + "rand_core", + "sec1", "subtle", + "zeroize", ] [[package]] -name = "fiat-crypto" -version = "0.2.9" +name = "ff" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] [[package]] name = "find-msvc-tools" @@ -123,16 +180,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -145,11 +192,43 @@ dependencies = [ "wasip2", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "image-verify" version = "0.0.1" dependencies = [ - "ed25519-dalek", + "p256", + "sha2", ] [[package]] @@ -187,21 +266,50 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "p256" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "unicode-ident", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", ] [[package]] -name = "quote" -version = "1.0.46" +name = "primefield" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "proc-macro2", + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "wnaf", ] [[package]] @@ -211,25 +319,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rfc6979" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "semver", + "crypto-bigint", + "hmac", ] [[package]] -name = "semver" -version = "1.0.28" +name = "sec1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures", @@ -244,9 +367,13 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest", + "rand_core", +] [[package]] name = "subtle" @@ -254,35 +381,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -297,3 +401,20 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/crates/image-verify/fuzz/fuzz_targets/verify_image.rs b/crates/image-verify/fuzz/fuzz_targets/verify_image.rs index 574519e..a3e39e7 100644 --- a/crates/image-verify/fuzz/fuzz_targets/verify_image.rs +++ b/crates/image-verify/fuzz/fuzz_targets/verify_image.rs @@ -1,12 +1,11 @@ #![no_main] -// Fuzz the signed firmware-image verifier against arbitrary, attacker-controlled -// bytes. The whole image is untrusted until the Ed25519 signature passes. The -// contract under test: verify_image must NEVER panic on any input. It returns -// either Ok (only for a genuinely valid image under the fixed pinned root key, -// which fuzzing will essentially never produce) or a typed error. The target -// exercises the bounded length/magic/version/algorithm parsing in front of the -// crypto. libFuzzer feeds mutated byte slices. Any panic/abort is a finding. +// Fuzz the segmented signed firmware-image verifier against arbitrary, +// attacker-controlled bytes. The whole image is untrusted until the ECDSA P-256 +// signature passes. The contract under test: verify_image must never panic on any +// input. +// +// Any panic or abort is a finding. use libfuzzer_sys::fuzz_target; diff --git a/crates/image-verify/src/encode.rs b/crates/image-verify/src/encode.rs index 0bf6e7b..d6abe37 100644 --- a/crates/image-verify/src/encode.rs +++ b/crates/image-verify/src/encode.rs @@ -1,13 +1,10 @@ //! Host-side header encoder for the signed firmware-image format. //! -//! Gated behind the `encode` feature so the on-device default build never pulls -//! it in. It writes the SAME fixed header the verifier reads, using the SAME -//! private offset constants, so the on-wire layout has a single source of truth. -//! -//! This module is `no_std` and heap-free: it writes a fixed-size stack array of -//! bytes and cannot fail, so it returns the array directly. +//! Gated behind the `encode` feature so the on-device default build does not +//! include it. It writes the same fixed header the verifier reads, using the same +//! offset constants, so the layout has a single source of truth. -use crate::format::ALG_ED25519; +use crate::format::ALG_ECDSA_P256_SHA256; use crate::format::FORMAT_VERSION; use crate::format::HEADER_LEN; use crate::format::ImageVersion; @@ -33,12 +30,12 @@ use crate::format::OFF_VERSION_REVISION; /// /// # Returns /// -/// A `HEADER_LEN`-byte array carrying the magic, the format version, the -/// Ed25519 algorithm id, the version fields little-endian, the security counter +/// A `HEADER_LEN`-byte array carrying the magic, the format version, the ECDSA +/// P-256 algorithm id, the version fields little-endian, the security counter /// little-endian, the payload length little-endian, and zero reserved bytes. /// -/// The caller signs `header || payload` and appends the 64-byte -/// signature to obtain a complete image the verifier accepts. +/// The caller signs `header || payload` and appends the 64-byte signature to +/// obtain a complete image the verifier accepts. pub fn encode_header ( version: ImageVersion, @@ -51,7 +48,7 @@ pub fn encode_header header[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC); header[OFF_FORMAT_VERSION] = FORMAT_VERSION; - header[OFF_ALGORITHM] = ALG_ED25519; + header[OFF_ALGORITHM] = ALG_ECDSA_P256_SHA256; header[OFF_VERSION_MAJOR] = version.major; header[OFF_VERSION_MINOR] = version.minor; header[OFF_VERSION_REVISION..OFF_VERSION_REVISION + 2] @@ -63,8 +60,8 @@ pub fn encode_header header[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4] .copy_from_slice(&payload_len.to_le_bytes()); - // The reserved bytes stay zero from the initialiser above. They are pinned - // here only to document the closing field and keep OFF_RESERVED referenced. + // The reserved bytes are already zero from the initialiser. Written explicitly + // to document the closing field and keep OFF_RESERVED referenced. header[OFF_RESERVED..OFF_RESERVED + 2].copy_from_slice(&[0u8, 0u8]); header @@ -74,13 +71,16 @@ pub fn encode_header mod tests { use super::*; + use crate::ROOT_KEY_LEN; use crate::RootKey; + use crate::SIG_LEN; use crate::verify_image; - use ed25519_dalek::SigningKey; - use ed25519_dalek::ed25519::signature::Signer; + use p256::ecdsa::SigningKey; + use p256::ecdsa::signature::Signer; use std::vec::Vec; - const SEED: [u8; 32] = [5u8; 32]; + // A fixed private scalar: non-zero and far below the curve order. + const SCALAR: [u8; 32] = [5u8; 32]; fn version() -> ImageVersion { @@ -100,7 +100,7 @@ mod tests assert_eq!(&header[OFF_MAGIC..OFF_MAGIC + 4], &MAGIC); assert_eq!(header[OFF_FORMAT_VERSION], FORMAT_VERSION); - assert_eq!(header[OFF_ALGORITHM], ALG_ED25519); + assert_eq!(header[OFF_ALGORITHM], ALG_ECDSA_P256_SHA256); assert_eq!(header[OFF_VERSION_MAJOR], 4); assert_eq!(header[OFF_VERSION_MINOR], 2); assert_eq!( @@ -122,9 +122,8 @@ mod tests assert_eq!(&header[OFF_RESERVED..OFF_RESERVED + 2], &[0u8, 0u8]); } - // Builds an image with the encoder, signs it, and proves the verifier - // accepts it and returns the same fields. This pins the encoder against the - // real verify path inside the crate that owns both. + // Builds an image with the encoder, signs it, and checks the verifier accepts + // it and returns the same fields. Pins the encoder against the real verify path. #[test] fn encoded_image_round_trips_through_verifier() { @@ -135,16 +134,27 @@ mod tests signed.extend_from_slice(&header); signed.extend_from_slice(payload); - let sk = SigningKey::from_bytes(&SEED); - let sig = sk.sign(&signed); + let sk = SigningKey::from_slice(&SCALAR).expect("scalar in [1, n-1]"); + let sig: p256::ecdsa::Signature = sk.sign(&signed); + let sig = sig.normalize_s(); let mut image = signed; image.extend_from_slice(&sig.to_bytes()); + assert_eq!(image.len(), HEADER_LEN + payload.len() + SIG_LEN); + + let point = sk.verifying_key().to_sec1_point(false); + let mut key_bytes = [0u8; ROOT_KEY_LEN]; + key_bytes.copy_from_slice(point.as_ref()); + let root = RootKey::from_bytes(key_bytes).expect("test key is valid"); - let root = RootKey::from_bytes(sk.verifying_key().to_bytes()) - .expect("test key is valid"); - let verified = verify_image(&image, &root).expect("must verify"); + let segs: [&[u8]; 1] = [&image]; + let verified = verify_image(&segs, &root).expect("must verify"); - assert_eq!(verified.payload(), payload); + let mut got = Vec::new(); + for piece in verified.payload_segments() + { + got.extend_from_slice(piece); + } + assert_eq!(got, payload); assert_eq!(verified.security_counter(), 9); let v = verified.image_version(); assert_eq!(v.major, 4); diff --git a/crates/image-verify/src/error.rs b/crates/image-verify/src/error.rs index 9665ba4..73c3f02 100644 --- a/crates/image-verify/src/error.rs +++ b/crates/image-verify/src/error.rs @@ -1,28 +1,26 @@ //! Fail-closed error for the signed firmware-image verifier. //! -//! Every variant is a rejection. The whole image (header plus payload) is -//! attacker-controlled until the Ed25519 signature verifies, so each structural -//! anomaly maps to a distinct, typed rejection and no trusted field is ever -//! exposed before the signature passes. No `Display`, no `std`. +//! Every variant is a rejection. The whole image is attacker-controlled until the +//! signature verifies, so each structural anomaly maps to a distinct typed +//! rejection and no trusted field is exposed before the signature passes. /// Why an image failed verification. /// -/// The variants below are checked in a fixed order (see [`crate::verify_image`]) -/// so the first anomaly wins. They communicate WHAT was wrong without leaking -/// any pre-verify field value. +/// Checked in a fixed order (see [`crate::verify_image`]), so the first anomaly +/// wins. Each variant says what was wrong without leaking any pre-verify field +/// value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VerifyError { - /// The slice was shorter than the minimum `HEADER_LEN + SIG_LEN` floor, so - /// it cannot even hold a header plus a signature. + /// The segments hold fewer bytes than the minimum `HEADER_LEN + SIG_LEN` + /// floor, so they cannot even hold a header plus a signature. TooShort, /// The leading magic tag did not match the patina_key image constant. BadMagic, /// The header `format_version` byte was not a value this parser supports. /// This is the parser-schema version, not the firmware version. UnsupportedFormatVersion, - /// The `algorithm` byte was not `0x01` (Ed25519). Future-proofs a potential - /// later P-256 swap without silently accepting it today. + /// The `algorithm` byte was not `0x02` (ECDSA P-256 over SHA-256). UnsupportedAlgorithm, /// The total length did not equal `HEADER_LEN + payload_len + SIG_LEN` /// exactly. Catches a truncated payload, an oversized declaration, a @@ -32,10 +30,17 @@ pub enum VerifyError /// signed region and are required to be zero, so a non-zero value is a /// structural rejection caught before the signature is even checked. ReservedNotZero, - /// The supplied root key was not a key Ed25519 accepts (a malformed or - /// non-canonical point). + /// The supplied root key was not an uncompressed SEC1 point on the P-256 + /// curve (a wrong tag byte, an off-curve point, or the identity). BadRootKey, - /// The Ed25519 signature did not verify under the pinned root key over - /// `HEADER || PAYLOAD`. Any tampered byte or a wrong signing key lands here. + /// The signature `s` scalar sits in the upper half of the curve order. ECDSA + /// admits two encodings, `(r, s)` and `(r, n - s)`, both of which verify. This + /// verifier accepts only the low-s encoding, so an image has one valid byte + /// string per signing key. See [`crate::verify_image`]. + NonCanonicalSignature, + /// The signature did not verify under the pinned root key over + /// `HEADER || PAYLOAD`. Also covers a signature whose `r` or `s` is zero or + /// at least the curve order, which is not a well-formed scalar pair. Any + /// tampered byte or a wrong signing key lands here. BadSignature, } diff --git a/crates/image-verify/src/format.rs b/crates/image-verify/src/format.rs index 06230c5..8d7c5c4 100644 --- a/crates/image-verify/src/format.rs +++ b/crates/image-verify/src/format.rs @@ -2,10 +2,8 @@ //! //! # Endianness //! -//! ALL multi-byte header fields are LITTLE-ENDIAN. The MCU is a Cortex-M33 -//! (little-endian), so this matches the native byte order and avoids a swap on -//! the device. Every reader/writer in this crate uses `u16::from_le_bytes` / -//! `u32::from_le_bytes` and the matching `to_le_bytes`. +//! All multi-byte header fields are little-endian, matching the Cortex-M33 native +//! byte order. //! //! # Layout //! @@ -13,8 +11,21 @@ //! HEADER (HEADER_LEN bytes) || PAYLOAD (payload_len bytes) || SIGNATURE (64) //! ``` //! -//! The Ed25519 SIGNATURE covers exactly `HEADER || PAYLOAD`: every byte except -//! the trailing 64 signature bytes. +//! The signature covers exactly `HEADER || PAYLOAD`: every byte except the +//! trailing 64 signature bytes. The verifier hashes that region with SHA-256 and +//! checks the ECDSA P-256 signature against the digest. +//! +//! # On-flash placement +//! +//! This file layout, one contiguous `HEADER || PAYLOAD || SIGNATURE`, is what the +//! signer emits and what a host tool sees. It does not change on the device. +//! +//! On the STM32 A/B target the device de-interleaves the file onto flash: the +//! header and signature land in a small descriptor page, and the payload lands +//! page-aligned at its link origin, so the firmware vector table sits where the CPU +//! fetches it. The verifier consumes the image as segments +//! whose logical concatenation is exactly this file, so placement is a device +//! concern. //! //! # Header field offsets //! @@ -23,7 +34,8 @@ //! --- ---- ---------------- ------ ------------------------------------ //! 0 4 magic [u8; 4] fixed tag "PKIM" (patina_key image) //! 4 1 format_version u8 header SCHEMA version (this parser) -//! 5 1 algorithm u8 signature algorithm id, 0x01=Ed25519 +//! 5 1 algorithm u8 signature algorithm id, 0x02 = the one +//! accepted algorithm, ECDSA P-256/SHA-256 //! 6 1 image_version_maj u8 firmware version major //! 7 1 image_version_min u8 firmware version minor //! 8 2 image_version_rev u16le firmware version revision @@ -35,52 +47,49 @@ //! 24 HEADER_LEN //! ``` //! -//! The image_version (major.minor.revision.build) and security_counter live -//! INSIDE the signed region. This crate only parses them. Anti-rollback -//! comparison is future work. +//! The image_version and security_counter live inside the signed region. This crate +//! only parses them. //! //! # Domain separation //! -//! The magic (4 bytes), format_version, and algorithm byte sit at the FRONT of -//! the signed region, so they are the in-band domain separator bound by the -//! signature. Because verification is a single contiguous no-allocation -//! verify_strict over HEADER || PAYLOAD, the domain tag is carried in-band as -//! the leading signed bytes rather than as a prepended context string. The MCU -//! image root key MUST sign nothing but MCU firmware images. That -//! key-use-exclusivity invariant is enforced by the signing ceremony, not by -//! this parser, and it is what keeps a signature over some other artifact from -//! ever being replayed as a firmware image. +//! The magic, format_version, and algorithm byte sit at the front of the signed +//! region, so they are the in-band domain separator bound by the signature. The +//! image root key must sign nothing but firmware images. That exclusivity is +//! enforced by the signing ceremony, not this parser, and it stops a signature over +//! another artifact from being replayed as a firmware image. //! -//! # Algorithm agility +//! # Exactly one algorithm ships //! -//! The algorithm byte exists so a second signature algorithm can be added -//! later, for example 0x02 for P-256. The hard precondition is that adding any -//! second algorithm REQUIRES the signature length, the key type, and the -//! signed-region split to be selected BY that algorithm byte. They must NOT be -//! bolted onto the fixed 64-byte Ed25519 signature and 32-byte key offsets. -//! Today only Ed25519 (RFC 8032) is accepted, with a fixed 64-byte trailing -//! signature. +//! The `algorithm` byte is a rejection guard. +//! The byte exists to reject anything else, including the retired Ed25519 id `0x01`. -/// The fixed 4-byte tag that identifies a patina_key signed image. ASCII -/// "PKIM" (Patina Key IMage). Little-endian byte order is irrelevant for a raw -/// `[u8; 4]` tag: it is compared byte-for-byte as written. +/// The fixed 4-byte tag identifying a patina_key signed image, ASCII "PKIM". +/// Compared byte for byte, so byte order does not apply. pub(crate) const MAGIC: [u8; 4] = *b"PKIM"; -/// The only header schema version this parser understands. An image carrying -/// any other `format_version` is rejected with `UnsupportedFormatVersion`. +/// The only header schema version this parser understands. Any other +/// `format_version` is rejected with `UnsupportedFormatVersion`. +/// +/// The schema is the field layout, not the algorithm. A retired algorithm is +/// rejected through the `algorithm` byte, not this version. pub(crate) const FORMAT_VERSION: u8 = 1; -/// The signature-algorithm id for Ed25519. The only value accepted today. The -/// byte exists so a later P-256 image can be distinguished rather than silently -/// misverified. -pub(crate) const ALG_ED25519: u8 = 0x01; +/// The signature-algorithm id for ECDSA P-256 over SHA-256, the only value this +/// verifier accepts. Any other value, including the retired Ed25519 id `0x01`, is +/// rejected with `UnsupportedAlgorithm`. +pub(crate) const ALG_ECDSA_P256_SHA256: u8 = 0x02; /// Length of the fixed-size header in bytes. pub const HEADER_LEN: usize = 24; -/// Length of the trailing Ed25519 signature in bytes. +/// Length of the trailing signature in bytes: the ECDSA P-256 `r || s` pair, +/// two 32-byte big-endian scalars, with no ASN.1 framing. pub const SIG_LEN: usize = 64; +/// Length of the pinned root public key in bytes: an UNCOMPRESSED SEC1 point, +/// the `0x04` tag then the 32-byte X and 32-byte Y coordinates. +pub const ROOT_KEY_LEN: usize = 65; + // Field offsets within the header. Private: callers read fields through the // verified accessors, never by raw offset. pub(crate) const OFF_MAGIC: usize = 0; @@ -95,16 +104,12 @@ pub(crate) const OFF_PAYLOAD_LEN: usize = 18; pub(crate) const OFF_RESERVED: usize = 22; // The reserved 2-byte pad closes the header exactly at HEADER_LEN. This -// compile-time check pins the layout and keeps OFF_RESERVED load-bearing -// outside the test build. +// compile-time check pins the layout. const _: () = assert!(OFF_RESERVED + 2 == HEADER_LEN); /// The firmware version carried in the header. /// -/// A plain public value type: the version numbers are not secret, so the fields -/// are public by design. Parsed from the signed region. Ordering is NOT defined -/// here on purpose: the anti-rollback policy that compares two versions is -/// future work. +/// Parsed from the signed region. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ImageVersion { diff --git a/crates/image-verify/src/lib.rs b/crates/image-verify/src/lib.rs index 89a2de2..87db8d2 100644 --- a/crates/image-verify/src/lib.rs +++ b/crates/image-verify/src/lib.rs @@ -1,20 +1,30 @@ //! Signed firmware-image verifier for patina_key. //! -//! A `no_std`, heap-free library that parses the patina_key -//! signed-image format and verifies its Ed25519 signature against an -//! out-of-band PINNED root public key supplied by the caller. -//! This crate decides one thing, is this image authentic under the pinned root. +//! Parses the patina_key signed-image format and verifies its ECDSA P-256 +//! signature against a pinned root public key supplied by the caller. `no_std` +//! and heap-free. +//! +//! # Segmented images +//! +//! [`verify_image`] takes `&[&[u8]]`, a list of slices whose concatenation is the +//! image. On the device an image spans two flash bands with different security +//! attributes, read through different address aliases, so no contiguous view +//! exists and there is no RAM to assemble one. A one-element list is a contiguous +//! image. +//! +//! The digest is streamed across the segments, so the image is never copied. The +//! header, the signature, or any field may straddle a segment boundary, and a +//! segment may be empty. ECDSA verifies over a prehash, so the segmented path +//! needs no reassembly. //! //! # Trust model //! -//! The entire image is attacker-controlled until the signature verifies. The -//! root public key is the ONLY trust input and it is a caller argument, so the -//! library stays testable and the real secure binary pins the genuine key -//! out-of-band as a const. No header field inside the signed region is exposed -//! before the Ed25519 check passes. `verify_strict` (not `verify`) is used so a -//! low-order or non-canonical key is rejected. +//! The whole image is attacker-controlled until the signature verifies. The root +//! public key is the only trust input and is a caller argument, so the boot stage +//! pins the genuine key out-of-band. No field inside the signed region is exposed +//! before the signature check passes. //! -//! See [`format`] for the exact byte layout and the little-endian choice. +//! See [`format`] for the byte layout. #![no_std] #![forbid(unsafe_code)] @@ -26,23 +36,37 @@ extern crate std; mod encode; mod error; mod format; +mod segments; #[cfg(feature = "encode")] pub use crate::encode::encode_header; pub use crate::error::VerifyError; -pub use crate::format::ImageVersion; pub use crate::format::HEADER_LEN; +pub use crate::format::ImageVersion; +pub use crate::format::ROOT_KEY_LEN; pub use crate::format::SIG_LEN; +pub use crate::segments::PayloadSegments; -use ed25519_dalek::Signature; -use ed25519_dalek::VerifyingKey; +use p256::ecdsa::Signature; +use p256::ecdsa::VerifyingKey; +use p256::ecdsa::signature::hazmat::PrehashVerifier; +use p256::elliptic_curve::scalar::IsHigh; +use sha2::Digest; +use sha2::Sha256; -/// A pinned Ed25519 root public key. +/// A pinned ECDSA P-256 root public key. +/// +/// Pinned as the 65-byte uncompressed SEC1 point (`0x04 || X || Y`). The +/// uncompressed form carries both coordinates in the clear, so the pinned constant +/// can be diffed byte for byte against the signing ceremony output, and it avoids a +/// point decompression on every construction. Fixing the length at 65 also pins the +/// encoding: a compressed key cannot be passed to [`RootKey::from_bytes`], so there +/// is no encoding ambiguity. /// -/// Constructed only through [`RootKey::from_bytes`], which rejects any encoding -/// Ed25519 refuses (a malformed or non-canonical point). Holding a `RootKey` -/// therefore means dalek already accepted the key. -// Debug is intentionally NOT derived so the key bytes can never reach logs. +/// Constructed only through [`RootKey::from_bytes`], which rejects anything that is +/// not a point on the P-256 curve. Holding a `RootKey` means the point is +/// validated. +// Debug is not derived so the key bytes cannot reach logs. #[derive(Clone)] pub struct RootKey { @@ -51,15 +75,16 @@ pub struct RootKey impl RootKey { - /// Builds a pinned root key from its 32-byte Ed25519 encoding. + /// Builds a pinned root key from its 65-byte uncompressed SEC1 encoding. /// /// # Errors /// - /// Returns [`VerifyError::BadRootKey`] if the bytes are not a valid - /// compressed Edwards point that dalek accepts. - pub fn from_bytes(bytes: [u8; 32]) -> Result + /// Returns [`VerifyError::BadRootKey`] if the bytes are not an uncompressed + /// SEC1 point on the P-256 curve: a wrong tag byte, an off-curve point, or + /// the identity are all rejected. + pub fn from_bytes(bytes: [u8; ROOT_KEY_LEN]) -> Result { - match VerifyingKey::from_bytes(&bytes) + match VerifyingKey::from_sec1_bytes(&bytes) { Ok(key) => Ok(RootKey { key }), Err(_) => Err(VerifyError::BadRootKey), @@ -69,15 +94,17 @@ impl RootKey /// A verified image view. /// -/// Returned ONLY after the Ed25519 signature passed. Every field here lives -/// inside the signed region, so reading it is safe: an attacker cannot forge it -/// without the root private key. The payload slice borrows the original image. +/// Returned only after the signature verifies. Every field lives inside the signed +/// region, so an attacker cannot forge it without the root private key. The payload +/// is exposed as borrowed segments, never copied. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct VerifiedImage<'a> { image_version: ImageVersion, security_counter: u32, - payload: &'a [u8], + segments: &'a [&'a [u8]], + payload_start: usize, + payload_len: usize, } impl<'a> VerifiedImage<'a> @@ -95,19 +122,27 @@ impl<'a> VerifiedImage<'a> self.security_counter } - /// The verified payload bytes (the region between the header and the - /// signature). - pub fn payload(&self) -> &'a [u8] + /// The payload length in bytes, as declared by the signed header. + pub fn payload_len(&self) -> usize + { + self.payload_len + } + + /// The verified payload, as borrowed pieces in logical order. + /// + /// Concatenating the yielded slices reproduces the payload. The pieces borrow + /// the caller's segments, so nothing is copied. A contiguous image yields one + /// piece, or none for an empty payload. + pub fn payload_segments(&self) -> PayloadSegments<'a> { - self.payload + PayloadSegments::new(self.segments, self.payload_start, self.payload_len) } } -// Reads a little-endian u16 at `off` from an already-bounds-checked header -// region. The caller guarantees `slice.len() >= off + 2`. -fn read_u16_le(slice: &[u8], off: usize) -> Result +// Reads a little-endian u16 at `off` from the fixed-size header buffer. +fn read_u16_le(header: &[u8], off: usize) -> Result { - let bytes = slice + let bytes = header .get(off..off + 2) .ok_or(VerifyError::TooShort)?; let arr: [u8; 2] = bytes @@ -117,9 +152,9 @@ fn read_u16_le(slice: &[u8], off: usize) -> Result } // Reads a little-endian u32 at `off`. Same contract as `read_u16_le`. -fn read_u32_le(slice: &[u8], off: usize) -> Result +fn read_u32_le(header: &[u8], off: usize) -> Result { - let bytes = slice + let bytes = header .get(off..off + 4) .ok_or(VerifyError::TooShort)?; let arr: [u8; 4] = bytes @@ -128,62 +163,88 @@ fn read_u32_le(slice: &[u8], off: usize) -> Result Ok(u32::from_le_bytes(arr)) } -/// Verifies a signed firmware image against a pinned root key. +/// Verifies a segmented signed firmware image against a pinned root key. /// /// # Arguments /// -/// - `image`: the full `HEADER || PAYLOAD || SIGNATURE` byte slice. Entirely +/// - `image`: the segments whose concatenation is +/// `HEADER || PAYLOAD || SIGNATURE`. Any segmentation is legal: an empty list, +/// empty segments, a header or signature straddling a boundary. The bytes are /// attacker-controlled until the signature check passes. -/// - `root_key`: the out-of-band pinned Ed25519 root public key. +/// - `root_key`: the pinned P-256 root public key. /// /// # Returns /// /// On success, a [`VerifiedImage`] exposing the signed `image_version`, -/// `security_counter`, and payload slice. +/// `security_counter`, and the payload as borrowed segments. +/// +/// # Only the low-s signature encoding is accepted +/// +/// ECDSA admits two encodings of every signature, `(r, s)` and `(r, n - s)`, both +/// of which verify and either of which can be produced from the other without the +/// private key. This verifier rejects the high-s encoding with +/// [`VerifyError::NonCanonicalSignature`]. +/// +/// The digest covers `HEADER || PAYLOAD`, not the trailing signature, so flipping +/// `s` to `n - s` yields a different byte string in flash that still verifies. This +/// forges nothing, both encodings authenticate the same payload, so no authenticity +/// property depends on the check. What it buys is canonicality: each payload has +/// exactly one accepted byte string per signing key, so an image hash or a +/// byte-for-byte diff of two banks means one thing. +/// +/// The signing tool normalizes to low-s, so a legitimate signer never trips this. A +/// hardware backend returning a high-s signature is normalized on the host. /// /// # Errors /// -/// Fails closed at the FIRST anomaly, in this fixed order: +/// Fails closed at the first anomaly, in this fixed order: /// [`VerifyError::TooShort`] (below the `HEADER_LEN + SIG_LEN` floor), /// [`VerifyError::BadMagic`], [`VerifyError::UnsupportedFormatVersion`], -/// [`VerifyError::UnsupportedAlgorithm`], [`VerifyError::ReservedNotZero`] (a -/// reserved header byte was not zero), [`VerifyError::LengthMismatch`] (the -/// total length is not `HEADER_LEN + payload_len + SIG_LEN` exactly, including -/// an overflowing `payload_len`), then [`VerifyError::BadSignature`] if Ed25519 -/// rejects the signature over `HEADER || PAYLOAD`. No field inside the signed -/// region is read into the result before that final check passes. +/// [`VerifyError::UnsupportedAlgorithm`], [`VerifyError::ReservedNotZero`], +/// [`VerifyError::LengthMismatch`] (the total is not +/// `HEADER_LEN + payload_len + SIG_LEN` exactly, including an overflowing +/// `payload_len`), [`VerifyError::BadSignature`] for a signature that is not a +/// well-formed `(r, s)` scalar pair, [`VerifyError::NonCanonicalSignature`] for a +/// high-s encoding, then [`VerifyError::BadSignature`] if ECDSA rejects the +/// signature over the SHA-256 digest of `HEADER || PAYLOAD`. No field inside the +/// signed region is exposed in the result before that final check passes. pub fn verify_image<'a> ( - image: &'a [u8], + image: &'a [&'a [u8]], root_key: &RootKey, ) -> Result, VerifyError> { use crate::format:: { - ALG_ED25519, FORMAT_VERSION, HEADER_LEN, MAGIC, OFF_ALGORITHM, + ALG_ECDSA_P256_SHA256, FORMAT_VERSION, HEADER_LEN, MAGIC, OFF_ALGORITHM, OFF_FORMAT_VERSION, OFF_MAGIC, OFF_PAYLOAD_LEN, OFF_RESERVED, OFF_SECURITY_COUNTER, OFF_VERSION_BUILD, OFF_VERSION_MAJOR, OFF_VERSION_MINOR, OFF_VERSION_REVISION, SIG_LEN, }; - // a. Length floor: must hold a header plus a signature. + // Length floor: the segments must hold at least a header plus a signature. This + // establishes the total, which every later bound is checked against. + let total = segments::total_len(image)?; let floor = HEADER_LEN .checked_add(SIG_LEN) .ok_or(VerifyError::TooShort)?; - if image.len() < floor + if total < floor { return Err(VerifyError::TooShort); } - // The full header is parsed UP FRONT into typed locals, using the bounded - // combinators with their reachable error variants. Nothing parsed here is - // returned or otherwise exposed before verify_strict returns Ok: only the - // local bindings exist, and the VerifiedImage is built solely on the Ok - // path. This keeps the verifier fail-closed by construction. + // Parse the full header into typed locals. Nothing here is exposed before the + // signature verifies: the VerifiedImage is built only on the Ok path, which + // keeps the verifier fail-closed by construction. + // + // The header is copied into a fixed 24-byte stack array because it may straddle + // a segment boundary. Only the header is copied, not the image. + let mut header = [0u8; HEADER_LEN]; + segments::copy_out(image, 0, &mut header)?; - // b. Magic. - let magic = image + // Magic tag. + let magic = header .get(OFF_MAGIC..OFF_MAGIC + 4) .ok_or(VerifyError::TooShort)?; if magic != MAGIC @@ -191,8 +252,8 @@ pub fn verify_image<'a> return Err(VerifyError::BadMagic); } - // c. Format version (parser schema, not firmware version). - let format_version = *image + // Header schema version, not the firmware version. + let format_version = *header .get(OFF_FORMAT_VERSION) .ok_or(VerifyError::TooShort)?; if format_version != FORMAT_VERSION @@ -200,38 +261,38 @@ pub fn verify_image<'a> return Err(VerifyError::UnsupportedFormatVersion); } - // d. Algorithm id. - let algorithm = *image + // Algorithm id. One verifier ships, so every other id is rejected, the retired + // Ed25519 id included. This is the anti-downgrade guard. + let algorithm = *header .get(OFF_ALGORITHM) .ok_or(VerifyError::TooShort)?; - if algorithm != ALG_ED25519 + if algorithm != ALG_ECDSA_P256_SHA256 { return Err(VerifyError::UnsupportedAlgorithm); } - // e. Firmware version (major.minor.revision.build), inside the signed - // region. Read into a local now, returned only after verification. + // Firmware version, inside the signed region. Returned only after verification. let image_version = ImageVersion { - major: *image + major: *header .get(OFF_VERSION_MAJOR) .ok_or(VerifyError::TooShort)?, - minor: *image + minor: *header .get(OFF_VERSION_MINOR) .ok_or(VerifyError::TooShort)?, - revision: read_u16_le(image, OFF_VERSION_REVISION)?, - build: read_u32_le(image, OFF_VERSION_BUILD)?, + revision: read_u16_le(&header, OFF_VERSION_REVISION)?, + build: read_u32_le(&header, OFF_VERSION_BUILD)?, }; - // f. Monotonic anti-rollback counter, inside the signed region. - let security_counter = read_u32_le(image, OFF_SECURITY_COUNTER)?; + // Monotonic anti-rollback counter, inside the signed region. + let security_counter = read_u32_le(&header, OFF_SECURITY_COUNTER)?; - // g. Declared payload length. - let payload_len = read_u32_le(image, OFF_PAYLOAD_LEN)? as usize; + // Declared payload length. + let payload_len = read_u32_le(&header, OFF_PAYLOAD_LEN)? as usize; - // h. Reserved bytes MUST be zero. They sit inside the signed region, so - // this is a structural rejection caught before the signature check. - let reserved = image + // Reserved bytes must be zero. They sit inside the signed region, so a non-zero + // value is a structural rejection caught before the signature check. + let reserved = header .get(OFF_RESERVED..OFF_RESERVED + 2) .ok_or(VerifyError::TooShort)?; if reserved != [0u8, 0u8] @@ -239,404 +300,151 @@ pub fn verify_image<'a> return Err(VerifyError::ReservedNotZero); } - // i. Exact total length: HEADER_LEN + payload_len + SIG_LEN, no overflow, - // no trailing byte, no short read. + // Exact total length: HEADER_LEN + payload_len + SIG_LEN, with no overflow and + // no trailing byte. This pins the signature boundary, which may fall inside a + // segment. let signed_len = HEADER_LEN .checked_add(payload_len) .ok_or(VerifyError::LengthMismatch)?; let total_len = signed_len .checked_add(SIG_LEN) .ok_or(VerifyError::LengthMismatch)?; - if image.len() != total_len + if total != total_len { return Err(VerifyError::LengthMismatch); } - // j. Split the signed region from the trailing signature, both via bounded - // slicing. - let signed = image - .get(..signed_len) - .ok_or(VerifyError::LengthMismatch)?; - let sig_bytes = image - .get(signed_len..total_len) - .ok_or(VerifyError::LengthMismatch)?; - let sig_arr: [u8; SIG_LEN] = sig_bytes - .try_into() - .map_err(|_| VerifyError::LengthMismatch)?; - let signature = Signature::from_bytes(&sig_arr); + // Copy the trailing signature into a fixed 64-byte stack array. It may straddle + // a boundary or start inside the segment the payload ends in, so it cannot be + // sliced out of a single segment. + let mut sig_bytes = [0u8; SIG_LEN]; + segments::copy_out(image, signed_len, &mut sig_bytes)?; - // k. The load-bearing trust step. verify_strict rejects low-order keys. - // Any failure collapses to BadSignature: nothing leaks about why. + // Parse (r, s). Rejects a zero scalar or one at or above the curve order, so the + // pair is well-formed before any curve arithmetic. + let signature = Signature::from_slice(&sig_bytes) + .map_err(|_| VerifyError::BadSignature)?; + + // Malleability policy: accept only the low-s encoding. See the doc comment + // above. + if bool::from(signature.s().is_high()) + { + return Err(VerifyError::NonCanonicalSignature); + } + + // Stream the digest over HEADER || PAYLOAD. Each segment is fed to SHA-256 as + // is, and the last piece is cut at the signature boundary, so the image is never + // assembled in RAM. + let mut hasher = Sha256::new(); + segments::for_each_prefix_piece(image, signed_len, |piece| hasher.update(piece))?; + let digest = hasher.finalize(); + + // The trust step. Any failure collapses to BadSignature, so nothing leaks about + // why. root_key .key - .verify_strict(signed, &signature) + .verify_prehash(&digest, &signature) .map_err(|_| VerifyError::BadSignature)?; - // Authenticated. Only now bind the payload slice and build the result from - // the already-parsed locals. - let payload = signed - .get(HEADER_LEN..signed_len) - .ok_or(VerifyError::LengthMismatch)?; - + // Authenticated. Build the result from the already-parsed locals. Ok(VerifiedImage { image_version, security_counter, - payload, + segments: image, + payload_start: HEADER_LEN, + payload_len, }) } /// Fuzzing seam. Exposes the attacker-facing verify path to libFuzzer harnesses. /// -/// Gated behind the `_fuzz` feature so the normal public API stays minimal. The -/// entry point must never panic on any input. Not part of the supported API. +/// Gated behind the `_fuzz` feature so the fixed dev key it carries cannot reach a +/// product build. The entry point must never panic on any input. Not part of the +/// supported API. #[cfg(feature = "_fuzz")] pub mod fuzz { + use crate::ROOT_KEY_LEN; use crate::RootKey; - // A FIXED, valid Ed25519 public key for the fuzz target. Its exact value is - // irrelevant: the target exercises the bounded parsing in front of the - // crypto, which fails closed on essentially every mutated input. The bytes - // below are the public key of the all-0x01 Ed25519 secret scalar, a - // genuinely on-curve point that from_bytes accepts. - const FUZZ_ROOT_KEY: [u8; 32] = [ - 0x8a, 0x88, 0xe3, 0xdd, 0x74, 0x09, 0xf1, 0x95, - 0xfd, 0x52, 0xdb, 0x2d, 0x3c, 0xba, 0x5d, 0x72, - 0xca, 0x67, 0x09, 0xbf, 0x1d, 0x94, 0x12, 0x1b, - 0xf3, 0x74, 0x88, 0x01, 0xb4, 0x0f, 0x6f, 0x5c, + /// A fixed, valid P-256 public key for the fuzz target. Test only. + /// + /// The uncompressed SEC1 public key of the all-`0x01` private scalar, a publicly + /// known value. A guard test pins that an image signed with the matching private + /// scalar is accepted, so the fuzzer reaches the verify path instead of bouncing + /// off a rejected key. + pub(crate) const FUZZ_ROOT_KEY_TEST_ONLY: [u8; ROOT_KEY_LEN] = [ + 0x04, 0x6f, 0xf0, 0x3b, 0x94, 0x92, 0x41, 0xce, + 0x1d, 0xad, 0xd4, 0x35, 0x19, 0xe6, 0x96, 0x0e, + 0x0a, 0x85, 0xb4, 0x1a, 0x69, 0xa0, 0x5c, 0x32, + 0x81, 0x03, 0xaa, 0x2b, 0xce, 0x15, 0x94, 0xca, + 0x16, 0x3c, 0x4f, 0x75, 0x3a, 0x55, 0xbf, 0x01, + 0xdc, 0x53, 0xf6, 0xc0, 0xb0, 0xc7, 0xee, 0xe7, + 0x8b, 0x40, 0xc6, 0xff, 0x7d, 0x25, 0xa9, 0x6e, + 0x22, 0x82, 0xb9, 0x89, 0xce, 0xf7, 0x1c, 0x14, + 0x4a, ]; - /// Drives the image verifier over arbitrary bytes under a fixed pinned root - /// key. Must never panic. Returns either `Ok` (only for a genuinely valid - /// image under that key, which fuzzing will essentially never produce) or a - /// typed error. Any panic/abort is a finding. + /// Drives the segmented image verifier over arbitrary bytes under a fixed pinned + /// root key. Must never panic. + /// + /// The first two bytes choose two cut points, so the header and the signature + /// are driven across segment boundaries and zero-length segments are reached. + /// The same bytes also go through the contiguous one-segment path, so every + /// input attacks both shapes. Any panic or abort is a finding. pub fn verify_image(data: &[u8]) { - if let Ok(root) = RootKey::from_bytes(FUZZ_ROOT_KEY) + let root = match RootKey::from_bytes(FUZZ_ROOT_KEY_TEST_ONLY) { - let _ = crate::verify_image(data, &root); - } + Ok(key) => key, + Err(_) => return, + }; + + // With fewer than two control bytes there is no image to cut. Drive the two + // edge shapes the parser must survive: an empty segment list, and a single + // segment holding whatever bytes there are. + let (control, body) = match data.split_at_checked(2) + { + Some(pair) => pair, + None => + { + let _ = crate::verify_image(&[], &root); + let _ = crate::verify_image(&[data], &root); + return; + } + }; + + // The contiguous shape. + let _ = crate::verify_image(&[body], &root); + + let cuts: [u8; 2] = match control.try_into() + { + Ok(pair) => pair, + Err(_) => return, + }; + + // The segmented shape. `span` is body.len() + 1, so `first` lands anywhere + // in [0, len] and `second` anywhere in [first, len]. Either cut can coincide + // with a boundary or an end, reaching the empty-segment and + // boundary-straddling cases. + let span = body.len().saturating_add(1); + let first = (cuts[0] as usize) % span; + let second = first + ((cuts[1] as usize) % (span - first)); + + let (head, rest) = match body.split_at_checked(first) + { + Some(pair) => pair, + None => return, + }; + let (middle, tail) = match rest.split_at_checked(second - first) + { + Some(pair) => pair, + None => return, + }; + let _ = crate::verify_image(&[head, middle, tail], &root); } } #[cfg(test)] -mod tests -{ - use super::*; - use crate::format::{ - ALG_ED25519, FORMAT_VERSION, MAGIC, OFF_ALGORITHM, OFF_FORMAT_VERSION, - OFF_MAGIC, OFF_PAYLOAD_LEN, OFF_RESERVED, OFF_SECURITY_COUNTER, - OFF_VERSION_BUILD, OFF_VERSION_MAJOR, OFF_VERSION_MINOR, - OFF_VERSION_REVISION, - }; - use ed25519_dalek::ed25519::signature::Signer; - use ed25519_dalek::SigningKey; - use std::vec::Vec; - - // Deterministic fixtures: a fixed 32-byte seed yields a stable key pair, no - // RNG needed. - const TEST_SEED: [u8; 32] = [7u8; 32]; - const OTHER_SEED: [u8; 32] = [9u8; 32]; - - const TEST_MAJOR: u8 = 3; - const TEST_MINOR: u8 = 7; - const TEST_REVISION: u16 = 0x0102; - const TEST_BUILD: u32 = 0xAABB_CCDD; - const TEST_COUNTER: u32 = 0x0000_1234; - - fn signing_key(seed: [u8; 32]) -> SigningKey - { - SigningKey::from_bytes(&seed) - } - - fn root_key_for(seed: [u8; 32]) -> RootKey - { - let sk = signing_key(seed); - let pk = sk.verifying_key().to_bytes(); - RootKey::from_bytes(pk).expect("test key is valid") - } - - // Builds a header with the given payload length. Returns a HEADER_LEN buffer. - fn build_header(payload_len: u32) -> [u8; HEADER_LEN] - { - let mut h = [0u8; HEADER_LEN]; - h[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC); - h[OFF_FORMAT_VERSION] = FORMAT_VERSION; - h[OFF_ALGORITHM] = ALG_ED25519; - h[OFF_VERSION_MAJOR] = TEST_MAJOR; - h[OFF_VERSION_MINOR] = TEST_MINOR; - h[OFF_VERSION_REVISION..OFF_VERSION_REVISION + 2] - .copy_from_slice(&TEST_REVISION.to_le_bytes()); - h[OFF_VERSION_BUILD..OFF_VERSION_BUILD + 4] - .copy_from_slice(&TEST_BUILD.to_le_bytes()); - h[OFF_SECURITY_COUNTER..OFF_SECURITY_COUNTER + 4] - .copy_from_slice(&TEST_COUNTER.to_le_bytes()); - h[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4] - .copy_from_slice(&payload_len.to_le_bytes()); - h - } - - // Builds a fully signed image: HEADER || payload || signature, signed with - // `seed`'s key over HEADER || payload. - fn build_signed_image(seed: [u8; 32], payload: &[u8]) -> Vec - { - let payload_len = payload.len() as u32; - let header = build_header(payload_len); - let mut signed = Vec::new(); - signed.extend_from_slice(&header); - signed.extend_from_slice(payload); - let sk = signing_key(seed); - let sig = sk.sign(&signed); - let mut image = signed; - image.extend_from_slice(&sig.to_bytes()); - image - } - - #[test] - fn header_offsets_and_consts_are_pinned() - { - assert_eq!(HEADER_LEN, 24); - assert_eq!(SIG_LEN, 64); - assert_eq!(MAGIC, *b"PKIM"); - assert_eq!(FORMAT_VERSION, 1); - assert_eq!(ALG_ED25519, 0x01); - assert_eq!(OFF_MAGIC, 0); - assert_eq!(OFF_FORMAT_VERSION, 4); - assert_eq!(OFF_ALGORITHM, 5); - assert_eq!(OFF_VERSION_MAJOR, 6); - assert_eq!(OFF_VERSION_MINOR, 7); - assert_eq!(OFF_VERSION_REVISION, 8); - assert_eq!(OFF_VERSION_BUILD, 10); - assert_eq!(OFF_SECURITY_COUNTER, 14); - assert_eq!(OFF_PAYLOAD_LEN, 18); - assert_eq!(OFF_RESERVED, 22); - } - - #[test] - fn valid_image_round_trips() - { - let payload = b"hello patina firmware payload"; - let image = build_signed_image(TEST_SEED, payload); - let root = root_key_for(TEST_SEED); - let v = verify_image(&image, &root).expect("valid image must verify"); - assert_eq!(v.payload(), payload); - assert_eq!(v.security_counter(), TEST_COUNTER); - let ver = v.image_version(); - assert_eq!(ver.major, TEST_MAJOR); - assert_eq!(ver.minor, TEST_MINOR); - assert_eq!(ver.revision, TEST_REVISION); - assert_eq!(ver.build, TEST_BUILD); - } - - #[test] - fn empty_payload_round_trips() - { - let image = build_signed_image(TEST_SEED, b""); - let root = root_key_for(TEST_SEED); - let v = verify_image(&image, &root).expect("empty payload must verify"); - assert_eq!(v.payload(), b""); - } - - #[test] - fn flipped_payload_byte_is_bad_signature() - { - let mut image = build_signed_image(TEST_SEED, b"some payload here"); - // A payload byte sits just past the header. - image[HEADER_LEN] ^= 0xFF; - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::BadSignature)); - } - - #[test] - fn wrong_magic_is_bad_magic() - { - let mut image = build_signed_image(TEST_SEED, b"x"); - image[OFF_MAGIC] ^= 0xFF; - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::BadMagic)); - } - - #[test] - fn bad_format_version_is_unsupported_format_version() - { - let mut image = build_signed_image(TEST_SEED, b"x"); - image[OFF_FORMAT_VERSION] = 0xEE; - let root = root_key_for(TEST_SEED); - assert_eq!( - verify_image(&image, &root), - Err(VerifyError::UnsupportedFormatVersion) - ); - } - - #[test] - fn wrong_algorithm_is_unsupported_algorithm() - { - let mut image = build_signed_image(TEST_SEED, b"x"); - image[OFF_ALGORITHM] = 0x02; - let root = root_key_for(TEST_SEED); - assert_eq!( - verify_image(&image, &root), - Err(VerifyError::UnsupportedAlgorithm) - ); - } - - #[test] - fn truncated_below_floor_is_too_short() - { - let image = build_signed_image(TEST_SEED, b"x"); - let root = root_key_for(TEST_SEED); - let short = &image[..HEADER_LEN + SIG_LEN - 1]; - assert_eq!(verify_image(short, &root), Err(VerifyError::TooShort)); - } - - #[test] - fn declared_payload_len_too_big_is_length_mismatch() - { - let mut image = build_signed_image(TEST_SEED, b"abc"); - // Inflate the declared payload_len by one. - let inflated = (3u32 + 1).to_le_bytes(); - image[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&inflated); - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::LengthMismatch)); - } - - #[test] - fn declared_payload_len_too_small_is_length_mismatch() - { - let mut image = build_signed_image(TEST_SEED, b"abc"); - let deflated = 2u32.to_le_bytes(); - image[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&deflated); - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::LengthMismatch)); - } - - #[test] - fn trailing_byte_is_length_mismatch() - { - let mut image = build_signed_image(TEST_SEED, b"abc"); - image.push(0x00); - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::LengthMismatch)); - } - - #[test] - fn overflowing_payload_len_is_length_mismatch() - { - let mut image = build_signed_image(TEST_SEED, b"abc"); - let huge = u32::MAX.to_le_bytes(); - image[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&huge); - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::LengthMismatch)); - } - - #[test] - fn wrong_signing_key_is_bad_signature() - { - // Signed with TEST_SEED, verified under OTHER_SEED's public key. - let image = build_signed_image(TEST_SEED, b"payload"); - let root = root_key_for(OTHER_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::BadSignature)); - } - - #[test] - fn bad_signature_image_exposes_nothing() - { - // The function returns Err, so no VerifiedImage exists and the signed - // fields are never readable on a tampered image. - let mut image = build_signed_image(TEST_SEED, b"payload"); - image[HEADER_LEN] ^= 0x01; - let root = root_key_for(TEST_SEED); - let result = verify_image(&image, &root); - assert!(result.is_err()); - assert_eq!(result, Err(VerifyError::BadSignature)); - } - - #[test] - fn security_counter_tamper_is_bad_signature() - { - // Flipping a byte of the signed security_counter must break the - // signature, proving the anti-rollback counter is bound by it. - let mut image = build_signed_image(TEST_SEED, b"payload"); - image[OFF_SECURITY_COUNTER] ^= 0xFF; - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::BadSignature)); - } - - #[test] - fn image_version_tamper_is_bad_signature() - { - // Flipping a byte inside the signed image_version range must break the - // signature, proving the firmware version is bound by it. - let mut image = build_signed_image(TEST_SEED, b"payload"); - image[OFF_VERSION_BUILD] ^= 0xFF; - let root = root_key_for(TEST_SEED); - assert_eq!(verify_image(&image, &root), Err(VerifyError::BadSignature)); - } - - #[test] - fn nonzero_reserved_is_reserved_not_zero() - { - // Set a reserved byte BEFORE signing so the signature is genuinely - // valid. The rejection then proves the reserved check is structural, - // not a side effect of a broken signature. - let payload = b"payload"; - let payload_len = payload.len() as u32; - let mut header = build_header(payload_len); - header[OFF_RESERVED] = 0x01; - let mut signed = Vec::new(); - signed.extend_from_slice(&header); - signed.extend_from_slice(payload); - let sk = signing_key(TEST_SEED); - let sig = sk.sign(&signed); - let mut image = signed; - image.extend_from_slice(&sig.to_bytes()); - let root = root_key_for(TEST_SEED); - assert_eq!( - verify_image(&image, &root), - Err(VerifyError::ReservedNotZero) - ); - } - - #[test] - fn from_bytes_rejects_non_canonical_key() - { - // The encoding y = 2 (little-endian [0x02, 0x00, ...]) is not a - // decompressible Edwards point: 1 - y^2 over 1 - d*y^2 is a non-square, - // so dalek's from_bytes rejects it at construction. - let mut bad = [0u8; 32]; - bad[0] = 2; - match RootKey::from_bytes(bad) - { - Err(e) => assert_eq!(e, VerifyError::BadRootKey), - Ok(_) => panic!("a non-canonical key must be rejected"), - } - } - - #[test] - fn from_bytes_accepts_valid_key() - { - let sk = signing_key(TEST_SEED); - let pk = sk.verifying_key().to_bytes(); - assert!(RootKey::from_bytes(pk).is_ok()); - } - - // Pins that the fuzz seam's fixed root key is a key dalek actually accepts, - // so the fuzz target truly drives verify_image rather than silently skipping - // on a rejected key. The constant is the public key of the all-0x01 secret - // scalar. - #[test] - fn fuzz_root_key_is_accepted() - { - let sk = signing_key([0x01u8; 32]); - let expected = sk.verifying_key().to_bytes(); - const FUZZ_ROOT_KEY: [u8; 32] = [ - 0x8a, 0x88, 0xe3, 0xdd, 0x74, 0x09, 0xf1, 0x95, - 0xfd, 0x52, 0xdb, 0x2d, 0x3c, 0xba, 0x5d, 0x72, - 0xca, 0x67, 0x09, 0xbf, 0x1d, 0x94, 0x12, 0x1b, - 0xf3, 0x74, 0x88, 0x01, 0xb4, 0x0f, 0x6f, 0x5c, - ]; - assert_eq!(FUZZ_ROOT_KEY, expected); - assert!(RootKey::from_bytes(FUZZ_ROOT_KEY).is_ok()); - } -} +mod tests; diff --git a/crates/image-verify/src/segments.rs b/crates/image-verify/src/segments.rs new file mode 100644 index 0000000..afe3311 --- /dev/null +++ b/crates/image-verify/src/segments.rs @@ -0,0 +1,222 @@ +//! Logical view over a segmented image. +//! +//! The image is a list of slices whose concatenation is +//! `HEADER || PAYLOAD || SIGNATURE`. On the device the two bands of a flash bank +//! carry different security attributes and are read through different address +//! aliases, so no contiguous view exists and there is no RAM to assemble one. +//! +//! This module walks the segments by logical offset. Only the header and the +//! signature are ever copied out, into fixed 24- and 64-byte stack arrays. A +//! segment may be empty, the list may be empty, and any field may straddle a +//! boundary. + +use crate::error::VerifyError; + +/// Sums the segment lengths into the total logical image length. +/// +/// # Errors +/// +/// [`VerifyError::LengthMismatch`] if the sum overflows `usize`. +pub(crate) fn total_len(segments: &[&[u8]]) -> Result +{ + let mut total: usize = 0; + for segment in segments + { + total = total + .checked_add(segment.len()) + .ok_or(VerifyError::LengthMismatch)?; + } + Ok(total) +} + +/// Copies the logical bytes `[start, start + out.len())` into `out`. +/// +/// Walks the segments, skipping past `start`, then fills `out` piece by piece. +/// The range may straddle any number of boundaries and may start or end inside a +/// segment. Used for the two fixed-size fields, the 24-byte header and the +/// 64-byte signature. +/// +/// # Errors +/// +/// [`VerifyError::TooShort`] if the segments hold fewer than `start + out.len()` +/// bytes. +pub(crate) fn copy_out +( + segments: &[&[u8]], + start: usize, + out: &mut [u8], +) + -> Result<(), VerifyError> +{ + let mut skip = start; + let mut written: usize = 0; + + for segment in segments + { + if written >= out.len() + { + break; + } + if skip >= segment.len() + { + // The whole segment sits before the range. An empty segment lands + // here too and is skipped. + skip -= segment.len(); + continue; + } + let src = segment + .get(skip..) + .ok_or(VerifyError::TooShort)?; + skip = 0; + let room = out + .len() + .checked_sub(written) + .ok_or(VerifyError::TooShort)?; + let take = core::cmp::min(room, src.len()); + let from = src + .get(..take) + .ok_or(VerifyError::TooShort)?; + let into = out + .get_mut(written..written + take) + .ok_or(VerifyError::TooShort)?; + into.copy_from_slice(from); + written += take; + } + + if written != out.len() + { + return Err(VerifyError::TooShort); + } + Ok(()) +} + +/// Hands the logical bytes `[0, end)` to `sink`, one borrowed piece per segment. +/// +/// Used to stream the digest: the caller passes a hasher update as `sink`, so the +/// signed region is fed to SHA-256 without being copied into one buffer. The last +/// piece is truncated at `end`, which may fall inside a segment. Empty segments are +/// skipped, so `sink` never sees an empty piece. +/// +/// # Errors +/// +/// [`VerifyError::LengthMismatch`] if the segments hold fewer than `end` bytes. +pub(crate) fn for_each_prefix_piece +( + segments: &[&[u8]], + end: usize, + mut sink: F, +) + -> Result<(), VerifyError> +where + F: FnMut(&[u8]), +{ + let mut remaining = end; + + for segment in segments + { + if remaining == 0 + { + break; + } + if segment.is_empty() + { + continue; + } + let take = core::cmp::min(remaining, segment.len()); + let piece = segment + .get(..take) + .ok_or(VerifyError::LengthMismatch)?; + sink(piece); + remaining -= take; + } + + if remaining != 0 + { + return Err(VerifyError::LengthMismatch); + } + Ok(()) +} + +/// The verified payload, yielded as borrowed pieces in logical order. +/// +/// Obtained from [`crate::VerifiedImage::payload_segments`]. Concatenating the +/// yielded slices reproduces the payload exactly. The iterator borrows the +/// original segments and copies nothing, so a caller can hash, stream, or flash +/// the payload with no allocation. It never yields an empty piece. +#[derive(Debug, Clone, Copy)] +pub struct PayloadSegments<'a> +{ + segments: &'a [&'a [u8]], + // Index of the segment the next piece starts in. + seg: usize, + // Byte offset of the next piece inside that segment. + off: usize, + // Payload bytes still owed. + remaining: usize, +} + +impl<'a> PayloadSegments<'a> +{ + /// Builds an iterator over the logical range `[start, start + len)`. + /// + /// The caller has already proven the range lies inside the segments (the + /// exact-total-length check in [`crate::verify_image`]), so this only positions + /// the cursor. A range past the end yields nothing, keeping the iterator + /// panic-free on any input. + pub(crate) fn new + ( + segments: &'a [&'a [u8]], + start: usize, + len: usize, + ) + -> PayloadSegments<'a> + { + let mut seg: usize = 0; + let mut skip = start; + + while let Some(segment) = segments.get(seg) + { + if skip < segment.len() + { + break; + } + skip -= segment.len(); + seg += 1; + } + + PayloadSegments + { + segments, + seg, + off: skip, + remaining: len, + } + } +} + +impl<'a> Iterator for PayloadSegments<'a> +{ + type Item = &'a [u8]; + + fn next(&mut self) -> Option<&'a [u8]> + { + while self.remaining > 0 + { + let segment = self.segments.get(self.seg)?; + let available = segment.len().saturating_sub(self.off); + if available == 0 + { + // An empty segment, or one already exhausted. Step over it. + self.seg += 1; + self.off = 0; + continue; + } + let take = core::cmp::min(available, self.remaining); + let piece = segment.get(self.off..self.off + take)?; + self.off += take; + self.remaining -= take; + return Some(piece); + } + None + } +} diff --git a/crates/image-verify/src/tests.rs b/crates/image-verify/src/tests.rs new file mode 100644 index 0000000..5e85be8 --- /dev/null +++ b/crates/image-verify/src/tests.rs @@ -0,0 +1,620 @@ +//! Host tests for the segmented ECDSA P-256 image verifier. +//! +//! Fixtures are minted with fixed private scalars, so every key pair and signature +//! is deterministic and no RNG runs. Signing uses RFC 6979 deterministic nonces, so +//! each fixture is reproducible byte for byte. + +use super::*; +use crate::format:: +{ + ALG_ECDSA_P256_SHA256, FORMAT_VERSION, MAGIC, OFF_ALGORITHM, + OFF_FORMAT_VERSION, OFF_MAGIC, OFF_PAYLOAD_LEN, OFF_RESERVED, + OFF_SECURITY_COUNTER, OFF_VERSION_BUILD, OFF_VERSION_MAJOR, + OFF_VERSION_MINOR, OFF_VERSION_REVISION, +}; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; +use std::vec::Vec; + +// Deterministic fixtures. Each value is a valid P-256 private scalar: non-zero +// and far below the curve order n, which starts with 0xFF. +const TEST_SCALAR: [u8; 32] = [7u8; 32]; +const OTHER_SCALAR: [u8; 32] = [9u8; 32]; + +// The all-0x01 scalar, the publicly known dev/test key the fuzz seam pins. It is +// used only by the fuzz-seam guard tests, so it is gated with them. +#[cfg(feature = "_fuzz")] +const DEV_SCALAR: [u8; 32] = [1u8; 32]; + +const TEST_MAJOR: u8 = 3; +const TEST_MINOR: u8 = 7; +const TEST_REVISION: u16 = 0x0102; +const TEST_BUILD: u32 = 0xAABB_CCDD; +const TEST_COUNTER: u32 = 0x0000_1234; + +fn signing_key(scalar: [u8; 32]) -> SigningKey +{ + SigningKey::from_slice(&scalar).expect("test scalar is in [1, n-1]") +} + +fn public_key_of(scalar: [u8; 32]) -> [u8; ROOT_KEY_LEN] +{ + let sk = signing_key(scalar); + let point = sk.verifying_key().to_sec1_point(false); + let mut out = [0u8; ROOT_KEY_LEN]; + out.copy_from_slice(point.as_ref()); + out +} + +fn root_key_for(scalar: [u8; 32]) -> RootKey +{ + RootKey::from_bytes(public_key_of(scalar)).expect("test key is valid") +} + +// Builds a header with the given payload length. Returns a HEADER_LEN buffer. +fn build_header(payload_len: u32) -> [u8; HEADER_LEN] +{ + let mut h = [0u8; HEADER_LEN]; + h[OFF_MAGIC..OFF_MAGIC + 4].copy_from_slice(&MAGIC); + h[OFF_FORMAT_VERSION] = FORMAT_VERSION; + h[OFF_ALGORITHM] = ALG_ECDSA_P256_SHA256; + h[OFF_VERSION_MAJOR] = TEST_MAJOR; + h[OFF_VERSION_MINOR] = TEST_MINOR; + h[OFF_VERSION_REVISION..OFF_VERSION_REVISION + 2] + .copy_from_slice(&TEST_REVISION.to_le_bytes()); + h[OFF_VERSION_BUILD..OFF_VERSION_BUILD + 4] + .copy_from_slice(&TEST_BUILD.to_le_bytes()); + h[OFF_SECURITY_COUNTER..OFF_SECURITY_COUNTER + 4] + .copy_from_slice(&TEST_COUNTER.to_le_bytes()); + h[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4] + .copy_from_slice(&payload_len.to_le_bytes()); + h +} + +// Signs `signed` with `scalar` and returns the low-s 64-byte r || s pair, the only +// encoding the verifier accepts. +fn sign_low_s(scalar: [u8; 32], signed: &[u8]) -> [u8; SIG_LEN] +{ + let sk = signing_key(scalar); + let sig: p256::ecdsa::Signature = sk.sign(signed); + let sig = sig.normalize_s(); + let mut out = [0u8; SIG_LEN]; + out.copy_from_slice(&sig.to_bytes()); + out +} + +// Builds a fully signed image: HEADER || payload || signature. +fn build_signed_image(scalar: [u8; 32], payload: &[u8]) -> Vec +{ + let header = build_header(payload.len() as u32); + let mut signed = Vec::new(); + signed.extend_from_slice(&header); + signed.extend_from_slice(payload); + let sig = sign_low_s(scalar, &signed); + let mut image = signed; + image.extend_from_slice(&sig); + image +} + +// Concatenates the payload segments back into one buffer, so a test can compare +// against the original payload bytes. +fn collect_payload(verified: &VerifiedImage<'_>) -> Vec +{ + let mut out = Vec::new(); + for piece in verified.payload_segments() + { + assert!(!piece.is_empty(), "the iterator must never yield an empty piece"); + out.extend_from_slice(piece); + } + out +} + +#[test] +fn header_offsets_and_consts_are_pinned() +{ + assert_eq!(HEADER_LEN, 24); + assert_eq!(SIG_LEN, 64); + assert_eq!(ROOT_KEY_LEN, 65); + assert_eq!(MAGIC, *b"PKIM"); + assert_eq!(FORMAT_VERSION, 1); + assert_eq!(ALG_ECDSA_P256_SHA256, 0x02); + assert_eq!(OFF_MAGIC, 0); + assert_eq!(OFF_FORMAT_VERSION, 4); + assert_eq!(OFF_ALGORITHM, 5); + assert_eq!(OFF_VERSION_MAJOR, 6); + assert_eq!(OFF_VERSION_MINOR, 7); + assert_eq!(OFF_VERSION_REVISION, 8); + assert_eq!(OFF_VERSION_BUILD, 10); + assert_eq!(OFF_SECURITY_COUNTER, 14); + assert_eq!(OFF_PAYLOAD_LEN, 18); + assert_eq!(OFF_RESERVED, 22); +} + +#[test] +fn valid_image_round_trips() +{ + let payload = b"hello patina firmware payload"; + let image = build_signed_image(TEST_SCALAR, payload); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + let v = verify_image(&segs, &root).expect("valid image must verify"); + assert_eq!(collect_payload(&v), payload); + assert_eq!(v.payload_len(), payload.len()); + assert_eq!(v.security_counter(), TEST_COUNTER); + let ver = v.image_version(); + assert_eq!(ver.major, TEST_MAJOR); + assert_eq!(ver.minor, TEST_MINOR); + assert_eq!(ver.revision, TEST_REVISION); + assert_eq!(ver.build, TEST_BUILD); +} + +#[test] +fn empty_payload_round_trips() +{ + let image = build_signed_image(TEST_SCALAR, b""); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + let v = verify_image(&segs, &root).expect("empty payload must verify"); + assert_eq!(v.payload_len(), 0); + assert_eq!(collect_payload(&v), b""); + assert_eq!(v.payload_segments().count(), 0); +} + +// The segmented property: the same image cut at every possible offset must verify +// identically. The cut walks through the header, the payload, and the signature, so +// both a header and a signature straddling a boundary are driven at every byte +// position. +#[test] +fn every_two_way_split_verifies_identically() +{ + let payload = b"a payload long enough to span a cut in many places"; + let image = build_signed_image(TEST_SCALAR, payload); + let root = root_key_for(TEST_SCALAR); + + for cut in 0..=image.len() + { + let (head, tail) = image.split_at(cut); + let segs: [&[u8]; 2] = [head, tail]; + let v = verify_image(&segs, &root) + .unwrap_or_else(|e| panic!("split at {cut} must verify, got {e:?}")); + assert_eq!(collect_payload(&v), payload, "payload wrong at cut {cut}"); + assert_eq!(v.security_counter(), TEST_COUNTER); + } +} + +// A three-way split with empty segments woven in at both ends and in the middle. +// The header, the payload, and the signature all straddle, and the parser must +// step over the empty segments without ever yielding or consuming a byte from +// them. +#[test] +fn empty_segments_are_stepped_over() +{ + let payload = b"straddling payload bytes"; + let image = build_signed_image(TEST_SCALAR, payload); + let root = root_key_for(TEST_SCALAR); + + // Cut inside the header (10) and inside the signature (image.len() - 20). + let first = 10; + let second = image.len() - 20; + let a = &image[..first]; + let b = &image[first..second]; + let c = &image[second..]; + + let segs: [&[u8]; 7] = [&[], a, &[], b, &[], c, &[]]; + let v = verify_image(&segs, &root).expect("empty segments must be skipped"); + assert_eq!(collect_payload(&v), payload); + assert_eq!(v.security_counter(), TEST_COUNTER); +} + +#[test] +fn an_empty_segment_list_is_too_short() +{ + let root = root_key_for(TEST_SCALAR); + assert_eq!(verify_image(&[], &root), Err(VerifyError::TooShort)); +} + +#[test] +fn a_list_of_only_empty_segments_is_too_short() +{ + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 3] = [&[], &[], &[]]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::TooShort)); +} + +#[test] +fn a_payload_split_across_segments_is_reassembled_in_order() +{ + // The payload itself is cut in three, so the iterator must yield three pieces + // in logical order. + let payload: Vec = (0..90u8).collect(); + let image = build_signed_image(TEST_SCALAR, &payload); + let root = root_key_for(TEST_SCALAR); + + let a = &image[..HEADER_LEN + 30]; + let b = &image[HEADER_LEN + 30..HEADER_LEN + 60]; + let c = &image[HEADER_LEN + 60..]; + let segs: [&[u8]; 3] = [a, b, c]; + let v = verify_image(&segs, &root).expect("verify"); + + let pieces: Vec<&[u8]> = v.payload_segments().collect(); + assert_eq!(pieces.len(), 3, "one piece per segment the payload spans"); + assert_eq!(collect_payload(&v), payload); +} + +#[test] +fn flipped_payload_byte_is_bad_signature() +{ + let mut image = build_signed_image(TEST_SCALAR, b"some payload here"); + image[HEADER_LEN] ^= 0xFF; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::BadSignature)); +} + +#[test] +fn wrong_magic_is_bad_magic() +{ + let mut image = build_signed_image(TEST_SCALAR, b"x"); + image[OFF_MAGIC] ^= 0xFF; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::BadMagic)); +} + +#[test] +fn bad_format_version_is_unsupported_format_version() +{ + let mut image = build_signed_image(TEST_SCALAR, b"x"); + image[OFF_FORMAT_VERSION] = 0xEE; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!( + verify_image(&segs, &root), + Err(VerifyError::UnsupportedFormatVersion) + ); +} + +// The retired Ed25519 id must be rejected, not accepted by a second verifier. This +// is the anti-downgrade guard: one algorithm ships and every other id fails. +#[test] +fn the_retired_ed25519_algorithm_id_is_rejected() +{ + let mut image = build_signed_image(TEST_SCALAR, b"x"); + image[OFF_ALGORITHM] = 0x01; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!( + verify_image(&segs, &root), + Err(VerifyError::UnsupportedAlgorithm) + ); +} + +#[test] +fn an_unknown_algorithm_id_is_rejected() +{ + let mut image = build_signed_image(TEST_SCALAR, b"x"); + image[OFF_ALGORITHM] = 0x03; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!( + verify_image(&segs, &root), + Err(VerifyError::UnsupportedAlgorithm) + ); +} + +#[test] +fn truncated_below_floor_is_too_short() +{ + let image = build_signed_image(TEST_SCALAR, b"x"); + let root = root_key_for(TEST_SCALAR); + let short = &image[..HEADER_LEN + SIG_LEN - 1]; + let segs: [&[u8]; 1] = [short]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::TooShort)); +} + +// The floor check must count the whole segment list, not one segment: a header +// spread over many tiny segments that together fall one byte short is still +// TooShort, and a parser that looked at only the first segment would say so for +// the wrong reason. +#[test] +fn a_short_image_spread_over_many_segments_is_too_short() +{ + let image = build_signed_image(TEST_SCALAR, b"x"); + let root = root_key_for(TEST_SCALAR); + let short = &image[..HEADER_LEN + SIG_LEN - 1]; + let pieces: Vec<&[u8]> = short.chunks(3).collect(); + assert_eq!(verify_image(&pieces, &root), Err(VerifyError::TooShort)); +} + +#[test] +fn declared_payload_len_too_big_is_length_mismatch() +{ + let mut image = build_signed_image(TEST_SCALAR, b"abc"); + let inflated = (3u32 + 1).to_le_bytes(); + image[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&inflated); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::LengthMismatch)); +} + +#[test] +fn declared_payload_len_too_small_is_length_mismatch() +{ + let mut image = build_signed_image(TEST_SCALAR, b"abc"); + let deflated = 2u32.to_le_bytes(); + image[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&deflated); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::LengthMismatch)); +} + +#[test] +fn trailing_byte_is_length_mismatch() +{ + let mut image = build_signed_image(TEST_SCALAR, b"abc"); + image.push(0x00); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::LengthMismatch)); +} + +// A trailing byte in a separate segment must be caught too: the total is what +// counts, not the shape of the split. +#[test] +fn a_trailing_segment_is_length_mismatch() +{ + let image = build_signed_image(TEST_SCALAR, b"abc"); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 2] = [&image, &[0x00]]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::LengthMismatch)); +} + +#[test] +fn overflowing_payload_len_is_length_mismatch() +{ + let mut image = build_signed_image(TEST_SCALAR, b"abc"); + let huge = u32::MAX.to_le_bytes(); + image[OFF_PAYLOAD_LEN..OFF_PAYLOAD_LEN + 4].copy_from_slice(&huge); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::LengthMismatch)); +} + +#[test] +fn wrong_signing_key_is_bad_signature() +{ + let image = build_signed_image(TEST_SCALAR, b"payload"); + let root = root_key_for(OTHER_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::BadSignature)); +} + +#[test] +fn an_all_zero_signature_is_bad_signature() +{ + // r = s = 0 is not a well-formed scalar pair, so the parse rejects it before + // any curve arithmetic runs. + let mut image = build_signed_image(TEST_SCALAR, b"payload"); + let start = image.len() - SIG_LEN; + image[start..].fill(0); + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::BadSignature)); +} + +#[test] +fn bad_signature_image_exposes_nothing() +{ + let mut image = build_signed_image(TEST_SCALAR, b"payload"); + image[HEADER_LEN] ^= 0x01; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + let result = verify_image(&segs, &root); + assert!(result.is_err()); + assert_eq!(result, Err(VerifyError::BadSignature)); +} + +#[test] +fn security_counter_tamper_is_bad_signature() +{ + let mut image = build_signed_image(TEST_SCALAR, b"payload"); + image[OFF_SECURITY_COUNTER] ^= 0xFF; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::BadSignature)); +} + +#[test] +fn image_version_tamper_is_bad_signature() +{ + let mut image = build_signed_image(TEST_SCALAR, b"payload"); + image[OFF_VERSION_BUILD] ^= 0xFF; + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!(verify_image(&segs, &root), Err(VerifyError::BadSignature)); +} + +#[test] +fn nonzero_reserved_is_reserved_not_zero() +{ + // Set a reserved byte before signing so the signature is genuinely valid. The + // rejection then proves the reserved check is structural, not a side effect of + // a broken signature. + let payload = b"payload"; + let mut header = build_header(payload.len() as u32); + header[OFF_RESERVED] = 0x01; + let mut signed = Vec::new(); + signed.extend_from_slice(&header); + signed.extend_from_slice(payload); + let sig = sign_low_s(TEST_SCALAR, &signed); + let mut image = signed; + image.extend_from_slice(&sig); + + let root = root_key_for(TEST_SCALAR); + let segs: [&[u8]; 1] = [&image]; + assert_eq!( + verify_image(&segs, &root), + Err(VerifyError::ReservedNotZero) + ); +} + +// The malleability policy, proven. Flipping s to n - s yields a signature ECDSA +// still considers valid over the same digest and key. The verifier must reject it +// as non-canonical and must still accept the low-s twin. Both halves matter: +// without the second the test could pass on an image broken for another reason. +#[test] +fn a_high_s_signature_is_rejected_and_its_low_s_twin_is_accepted() +{ + let payload = b"malleability policy payload"; + let image = build_signed_image(TEST_SCALAR, payload); + let root = root_key_for(TEST_SCALAR); + + // The low-s twin (the image as built) is accepted. + let segs: [&[u8]; 1] = [&image]; + assert!(verify_image(&segs, &root).is_ok(), "the low-s image must verify"); + + // Rebuild the same signature with s replaced by n - s. Only the s half of the + // trailing 64 bytes changes, the digest and the key are untouched. + let start = image.len() - SIG_LEN; + let low = p256::ecdsa::Signature::from_slice(&image[start..]) + .expect("the built signature parses"); + let (r, s) = low.split_scalars(); + let high = p256::ecdsa::Signature::from_scalars(r, -s) + .expect("n - s is a valid non-zero scalar"); + assert!( + bool::from(high.s().is_high()), + "the flipped signature must actually be high-s" + ); + + let mut malleable = image.clone(); + malleable[start..].copy_from_slice(&high.to_bytes()); + assert_ne!(malleable, image, "the flipped image must differ in flash"); + + let segs: [&[u8]; 1] = [&malleable]; + assert_eq!( + verify_image(&segs, &root), + Err(VerifyError::NonCanonicalSignature), + "the high-s encoding must be rejected by policy" + ); + + // Non-vacuity: raw ECDSA (with no low-s policy) does accept the flipped + // signature over the same digest, so the rejection above comes from the + // policy, not from a broken image. + use p256::ecdsa::signature::hazmat::PrehashVerifier; + let digest = ::digest(&image[..start]); + let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&public_key_of(TEST_SCALAR)) + .expect("key"); + assert!( + key.verify_prehash(&digest, &high).is_ok(), + "raw ECDSA accepts the high-s twin, which is exactly why the policy exists" + ); +} + +#[test] +fn from_bytes_rejects_an_off_curve_point() +{ + // A well-formed uncompressed tag with coordinates that satisfy no curve + // equation. The point must be rejected at construction. + let mut bad = [0u8; ROOT_KEY_LEN]; + bad[0] = 0x04; + bad[1] = 0x01; + bad[33] = 0x01; + match RootKey::from_bytes(bad) + { + Err(e) => assert_eq!(e, VerifyError::BadRootKey), + Ok(_) => panic!("an off-curve point must be rejected"), + } +} + +#[test] +fn from_bytes_rejects_a_wrong_tag_byte() +{ + // A valid key with its 0x04 uncompressed tag replaced. A 65-byte buffer + // tagged 0x02 or 0x03 is not a legal SEC1 encoding, so it must be rejected: + // the pinned encoding is uncompressed and nothing else. + let mut bad = public_key_of(TEST_SCALAR); + bad[0] = 0x02; + match RootKey::from_bytes(bad) + { + Err(e) => assert_eq!(e, VerifyError::BadRootKey), + Ok(_) => panic!("a 65-byte buffer with a compressed tag must be rejected"), + } +} + +#[test] +fn from_bytes_rejects_an_all_zero_buffer() +{ + match RootKey::from_bytes([0u8; ROOT_KEY_LEN]) + { + Err(e) => assert_eq!(e, VerifyError::BadRootKey), + Ok(_) => panic!("an all-zero buffer must be rejected"), + } +} + +#[test] +fn from_bytes_accepts_valid_key() +{ + assert!(RootKey::from_bytes(public_key_of(TEST_SCALAR)).is_ok()); +} + +// Pins that the fuzz seam's fixed root key is the public key of the all-0x01 +// scalar and that the verifier accepts it. +#[cfg(feature = "_fuzz")] +#[test] +fn fuzz_root_key_is_the_dev_scalar_public_key() +{ + assert_eq!(crate::fuzz::FUZZ_ROOT_KEY_TEST_ONLY, public_key_of(DEV_SCALAR)); + assert!(RootKey::from_bytes(crate::fuzz::FUZZ_ROOT_KEY_TEST_ONLY).is_ok()); +} + +// The guard. An image signed with the fuzz seam's matching private scalar must be +// accepted, in both the contiguous and the segmented shape. Without this, a fuzz +// key that failed to parse, or a seam that never reached the crypto, would leave +// the fuzzer exploring only the reject path while reporting coverage. +#[cfg(feature = "_fuzz")] +#[test] +fn the_fuzz_seam_accepts_an_image_signed_with_its_matching_scalar() +{ + let payload = b"the fuzz seam must reach a genuine accept"; + let image = build_signed_image(DEV_SCALAR, payload); + let root = RootKey::from_bytes(crate::fuzz::FUZZ_ROOT_KEY_TEST_ONLY) + .expect("the fuzz root key is valid"); + + let segs: [&[u8]; 1] = [&image]; + let v = verify_image(&segs, &root).expect("the fuzz key must ACCEPT its own image"); + assert_eq!(collect_payload(&v), payload); + + // The same image, cut through the header and through the signature, must also + // be accepted, so the segmented path the fuzz target drives really reaches the + // verify. + let cut = image.len() - 30; + let segs: [&[u8]; 2] = [&image[..7], &image[7..]]; + assert!(verify_image(&segs, &root).is_ok(), "a header-straddling split must verify"); + let segs: [&[u8]; 2] = [&image[..cut], &image[cut..]]; + assert!(verify_image(&segs, &root).is_ok(), "a signature-straddling split must verify"); +} + +// The fuzz entry point itself must never panic, on any shape of input, including +// the degenerate ones (empty, one byte, exactly two control bytes). +#[cfg(feature = "_fuzz")] +#[test] +fn the_fuzz_entry_point_survives_degenerate_inputs() +{ + crate::fuzz::verify_image(&[]); + crate::fuzz::verify_image(&[0x00]); + crate::fuzz::verify_image(&[0xFF, 0xFF]); + crate::fuzz::verify_image(&[0x00, 0x00, 0x01, 0x02, 0x03]); + + // A real image behind two control bytes, so the seam's segmented path runs + // over a well-formed image at every cut the control bytes can pick. + let image = build_signed_image(DEV_SCALAR, b"fuzz seam payload"); + for a in [0u8, 1, 37, 200, 255] + { + for b in [0u8, 1, 37, 200, 255] + { + let mut data = std::vec![a, b]; + data.extend_from_slice(&image); + crate::fuzz::verify_image(&data); + } + } +} diff --git a/crates/mcu-flash/Cargo.toml b/crates/mcu-flash/Cargo.toml index b8548c0..a11a322 100644 --- a/crates/mcu-flash/Cargo.toml +++ b/crates/mcu-flash/Cargo.toml @@ -17,9 +17,10 @@ fw-update = { path = "../fw-update" } [dev-dependencies] # Host-only integration fixture: the A/B machine re-cabled onto the real driver # over the FLASH-controller model is driven through the public Updater API, and -# minting a valid signed image needs the verifier crate plus a signing key. +# minting a valid signed image needs the verifier crate plus an ECDSA P-256 +# signing key. image-verify = { path = "../image-verify", features = ["encode"] } -ed25519-dalek = { workspace = true } +p256 = { workspace = true } # This crate owns the volatile FLASH MMIO # (word reads / writes of the controller registers and the memory-mapped banks), diff --git a/crates/mcu-flash/src/bus.rs b/crates/mcu-flash/src/bus.rs index 336a04c..3385c6a 100644 --- a/crates/mcu-flash/src/bus.rs +++ b/crates/mcu-flash/src/bus.rs @@ -7,15 +7,15 @@ //! readback is a load). This mirrors the `mcu-spi` `SpiBusAccess` pattern. //! //! Two implementations exist: -//! - [`MmioFlash`]: the real one (volatile word MMIO). This is the ONLY -//! `unsafe` surface of the crate, each block carrying a `// SAFETY:` note. -//! It is gated to the embedded target so the host build never references a -//! fixed MMIO address and never compiles a real flash access. -//! - the host FLASH-controller model (test-only, in `model`): it models the -//! real controller state (the BSY / WDW handshake, the rc_w1 error flags, -//! program-clears-bits, the staged SWAP_BANK applied only at a modelled -//! reset), so the driver's sequencing and fail-closed paths are host-tested -//! against faithful silicon behaviour, not a per-address value queue. +//! - [`MmioFlash`]: the real one (volatile word MMIO). This is the crate's sole +//! `unsafe` surface, each block carrying a `// SAFETY:` note. It is gated to the +//! embedded target so the host build never references a fixed MMIO address and +//! never compiles a real flash access. +//! - the host FLASH-controller model (test-only, in `model`): it models the real +//! controller state (the BSY / WDW handshake, the rc_w1 error flags, +//! program-clears-bits, the staged SWAP_BANK applied only at a modelled reset), +//! so the driver's sequencing and fail-closed paths are host-tested against +//! faithful silicon behaviour, not a per-address value queue. /// A 32-bit register-access port for the FLASH driver. /// @@ -32,10 +32,10 @@ pub trait FlashAccess /// Read-modify-writes `addr`: clears the bits in `clear`, then sets `set`. /// - /// Applied as `(old & !clear) | set`. The default impl composes `read32` - /// and `write32`. It is for CONTROL registers only, where a read returns - /// the live control value. It must never target a STATUS register whose - /// flags change on their own. + /// Applied as `(old & !clear) | set`. The default impl composes `read32` and + /// `write32`. It is for control registers only, where a read returns the live + /// control value. It must never target a status register whose flags change on + /// their own. fn modify32(&mut self, addr: u32, clear: u32, set: u32) { let old = self.read32(addr); @@ -52,21 +52,21 @@ pub trait FlashAccess /// Borrows `len` bytes of memory-mapped flash at `base` as a slice. /// - /// On real silicon the inactive bank is memory-mapped, so this is a borrow - /// of the mapped region with no copy. The host model returns a borrow of its - /// backing bytes. The seam uses this so verify reads the EXACT bytes commit - /// boots, the verified image and the committed image being the same bytes by - /// construction. The [`fw_update::FlashSeam`] trait this driver implements - /// exposes the same borrow as `inactive_bank`. + /// On real silicon the inactive bank is memory-mapped, so this is a borrow of the + /// mapped region with no copy. The host model returns a borrow of its backing + /// bytes. The seam uses this so verify reads the exact bytes commit boots, the + /// verified image and the committed image being the same bytes by construction. + /// The [`fw_update::FlashSeam`] trait this driver implements exposes these borrows + /// as `inactive_descriptor`, `inactive_secure_band`, and `inactive_ns_band`. fn bank_view(&self, base: u32, len: usize) -> &[u8]; } /// The real memory-mapped-I/O port for the FLASH controller (hardware only). /// /// Volatile 32-bit accesses to the FLASH registers and the memory-mapped bank. -/// It is gated to `target_os = "none"` so the host (test) build never compiles -/// a fixed-address dereference. -/// Host code drives the driver through the FLASH-controller model instead. +/// Gated to `target_os = "none"` so the host (test) build never compiles a +/// fixed-address dereference. Host code drives the driver through the +/// FLASH-controller model instead. #[cfg(target_os = "none")] pub struct MmioFlash; diff --git a/crates/mcu-flash/src/driver.rs b/crates/mcu-flash/src/driver.rs index 2c929d0..15f8481 100644 --- a/crates/mcu-flash/src/driver.rs +++ b/crates/mcu-flash/src/driver.rs @@ -11,37 +11,36 @@ //! //! # The physical-bank-versus-mapped-address contract (RM0456 sec 7.5.8) //! -//! SWAP_BANK remaps the ADDRESS of each bank, but the BKER erase selector and -//! the SECWM / WRP protections follow the PHYSICAL bank (RM0456 sec 7.5.8 Fig -//! 23/24). So erase (BKER) and program / read (address) must be derived from the -//! SAME physical bank or they diverge under SWAP_BANK=1. This driver names a -//! physical bank with [`regs::PhysBank`] and asks it for both the BKER bit and -//! the mapped base, reading `OPTR.SWAP_BANK` (RM0456 sec 7.9.13) at runtime on -//! every address computation. The inactive-bank erase, program, and read all go -//! through the same physical bank, and the fixed-Bank-1 metadata band re-derives -//! its mapped address from SWAP_BANK on every access, so the NVCNT, the pending -//! record, the boot-count, and the update-outcome record survive a swap. +//! SWAP_BANK remaps the address of each bank, but the BKER erase selector and the +//! SECWM / WRP protections follow the physical bank (RM0456 sec 7.5.8 Fig 23/24). So +//! erase (BKER) and program / read (address) must be derived from the same physical +//! bank or they diverge under SWAP_BANK=1. This driver names a physical bank with +//! [`regs::PhysBank`] and asks it for both the BKER bit and the mapped base, reading +//! `OPTR.SWAP_BANK` (RM0456 sec 7.9.13) at runtime on every address computation. The +//! inactive-bank erase, program, and read all go through the same physical bank, and +//! the fixed-Bank-1 metadata band re-derives its mapped address from SWAP_BANK on +//! every access, so the NVCNT, the pending record, the boot-count, and the +//! update-outcome record survive a swap. //! //! # Posture assertion before any destructive op //! -//! Erase, program, and the swap arm all assert `OPTR.DUALBANK` and `OPTR.TZEN` -//! first (RM0456 sec 7.9.13). A mis-provisioned part (single-bank or TZEN clear) -//! means the geometry the constants pin does not hold, so the driver fails closed -//! with [`FlashError::Hardware`] rather than erasing or programming blind. +//! Erase, program, and the swap arm all assert `OPTR.DUALBANK` and `OPTR.TZEN` first +//! (RM0456 sec 7.9.13). A mis-provisioned part (single-bank or TZEN clear) means the +//! geometry the constants pin does not hold, so the driver fails closed with +//! [`FlashError::Hardware`] rather than erasing or programming blind. //! //! # Brick-safety: the option-byte / SWAP_BANK path is present but inert //! //! The [`Stm32FlashSeam`] [`commit_swap`](fw_update::FlashSeam::commit_swap) and -//! [`revert_swap`](fw_update::FlashSeam::revert_swap) impls carry the -//! FULL real register sequence (OPTR SWAP_BANK plus OPTSTRT plus OBL_LAUNCH, -//! RM0456 sec 7.4.2). OBL_LAUNCH triggers the reset that applies the option load -//! on real silicon, so it is the IRREVERSIBLE, brick-class step. The whole real -//! register surface is the [`FlashAccess`] MMIO port, which is gated to -//! `target_os = "none"` and does not compile on the host. NO host build and NO -//! test ever drives a real option-byte write: the tests run a state model that -//! stages the swap and applies it only at a modelled reset, never a real -//! OBL_LAUNCH. The capability is complete but inert. Its on-silicon invocation -//! stays gated on a deliberate operator action. +//! [`revert_swap`](fw_update::FlashSeam::revert_swap) impls carry the full real +//! register sequence (OPTR SWAP_BANK plus OPTSTRT plus OBL_LAUNCH, RM0456 sec 7.4.2). +//! OBL_LAUNCH triggers the reset that applies the option load on real silicon, the +//! irreversible brick-class step. The whole real register surface is the +//! [`FlashAccess`] MMIO port, which is gated to `target_os = "none"` and does not +//! compile on the host. No host build and no test ever drives a real option-byte +//! write: the tests run a state model that stages the swap and applies it only at a +//! modelled reset, never a real OBL_LAUNCH. The capability is complete but inert. Its +//! on-silicon invocation stays gated on a deliberate operator action. use fw_update::BankId; use fw_update::FlashError; @@ -111,9 +110,9 @@ where /// The mapped secure-alias base of a physical bank for the live SWAP_BANK. /// - /// This is the ONE helper the B1 resolution turns on: it pairs the physical - /// bank with the current SWAP_BANK state to yield the address erase and - /// program must both use (RM0456 sec 7.5.8). + /// This is the one helper the B1 resolution turns on: it pairs the physical bank + /// with the current SWAP_BANK state to yield the address erase and program must + /// both use (RM0456 sec 7.5.8). fn phys_base(&mut self, bank: PhysBank) -> u32 { bank.mapped_base(self.swap_bank()) @@ -128,19 +127,20 @@ where regs::inactive_phys_bank(self.swap_bank()) } - /// Polls `SECSR.BSY` and `SECSR.WDW` down to clear, bounded. + /// Polls `BSY` and `WDW` in the given status register down to clear, bounded. /// /// RM0456 sec 7.3.7 / 7.3.6: a program or erase must wait for BSY to clear, - /// and a program must also see WDW clear before the next data write. A - /// bounded spin fails closed with [`FlashError::Hardware`] rather than - /// hanging. - fn wait_ready(&mut self) -> Result<(), FlashError> + /// and a program must also see WDW clear before the next data write. `sr` is + /// SECSR for the secure controller or NSSR for the non-secure controller + /// (the BSY / WDW positions match, RM0456 sec 7.9.7 / 7.9.8). A bounded spin + /// fails closed with [`FlashError::Hardware`] rather than hanging. + fn wait_ready_on(&mut self, sr: u32) -> Result<(), FlashError> { let mut spins = 0u32; loop { - let sr = self.access.read32(regs::FLASH_SECSR); - if sr & (regs::SR_BSY | regs::SR_WDW) == 0 + let status = self.access.read32(sr); + if status & (regs::SR_BSY | regs::SR_WDW) == 0 { return Ok(()); } @@ -154,65 +154,69 @@ where } } - /// Clears every program / erase error flag (rc_w1) in `SECSR`. + /// Clears every program / erase error flag (rc_w1) in the given status reg. /// - /// RM0456 sec 7.9.8: each error flag is rc_w1, write 1 to clear. Clearing - /// from a known state before every op is part of failing closed. - fn clear_errors(&mut self) + /// RM0456 sec 7.9.7 / 7.9.8: each error flag is rc_w1, write 1 to clear. + /// Clearing from a known state before every op is part of failing closed. + fn clear_errors_on(&mut self, sr: u32) { - self.access.write32(regs::FLASH_SECSR, regs::SR_ALL_ERRORS); + self.access.write32(sr, regs::SR_ALL_ERRORS); } - /// Reads `SECSR` and maps any error flag to a typed [`FlashError`]. + /// Reads the given status register and maps any error flag to an error. /// - /// RM0456 sec 7.9.8: PROGERR, WRPERR, PGAERR, SIZERR, PGSERR, OPERR. Any set - /// flag means the op did not take effect, so it fails closed. - fn check_errors(&mut self) -> Result<(), FlashError> + /// RM0456 sec 7.9.7 / 7.9.8: PROGERR, WRPERR, PGAERR, SIZERR, PGSERR, OPERR. + /// Any set flag means the op did not take effect, so it fails closed. A + /// secure access to a non-secure page raises WRPERR here (Write-Ignored, + /// RM0456 Table 68). + fn check_errors_on(&mut self, sr: u32) -> Result<(), FlashError> { - let sr = self.access.read32(regs::FLASH_SECSR); - if sr & regs::SR_ALL_ERRORS != 0 + let status = self.access.read32(sr); + if status & regs::SR_ALL_ERRORS != 0 { return Err(FlashError::WriteFailed); } Ok(()) } - /// Unlocks the secure control register with the KEY1 / KEY2 sequence. + /// Unlocks the given control register with the KEY1 / KEY2 sequence. /// - /// RM0456 sec 7.3.5: write KEY1 then KEY2 to SECKEYR. A wrong value or order - /// locks the CR until reset, so the driver only writes the canonical pair. - /// A no-op if the CR is already unlocked. - fn unlock_cr(&mut self) + /// RM0456 sec 7.3.5: write KEY1 then KEY2 to the CR's key register. A wrong + /// value or order locks the CR until reset, so the driver only writes the + /// canonical pair. `cr` is SECCR or NSCR, `keyr` its matching key register. + /// The LOCK bit is bit 31 in both CRs. A no-op if already unlocked. + fn unlock_cr_on(&mut self, cr: u32, keyr: u32) { - let cr = self.access.read32(regs::FLASH_SECCR); - if cr & regs::SECCR_LOCK == 0 + if self.access.read32(cr) & regs::SECCR_LOCK == 0 { return; } - self.access.write32(regs::FLASH_SECKEYR, regs::FLASH_KEY1); - self.access.write32(regs::FLASH_SECKEYR, regs::FLASH_KEY2); + self.access.write32(keyr, regs::FLASH_KEY1); + self.access.write32(keyr, regs::FLASH_KEY2); } - /// Re-locks the secure control register, returning to a known idle state. + /// Re-locks the given control register, returning to a known idle state. /// - /// RM0456 sec 7.9.10: setting `SECCR.LOCK` re-locks the CR. The driver locks - /// after every op so a later op must unlock deliberately. - fn lock_cr(&mut self) + /// RM0456 sec 7.9.9 / 7.9.10: setting the CR LOCK bit re-locks it. The driver + /// locks after every op so a later op must unlock deliberately. + fn lock_cr_on(&mut self, cr: u32) { - self.access - .modify32(regs::FLASH_SECCR, 0, regs::SECCR_LOCK); + self.access.modify32(cr, 0, regs::SECCR_LOCK); } - /// Programs one 16-byte quad-word at `addr` from up to 16 bytes of `data`. + /// Programs one 16-byte quad-word at `addr` on the given band's controller. /// /// RM0456 sec 7.3.7: poll ready, clear errors, set PG, write 4 consecutive /// 32-bit words to a quad-word-aligned address, poll BSY, check EOP, clear /// PG. A short tail pads with the erased value so a sub-quad-word write never - /// raises SIZERR. `addr` MUST be quad-word aligned. The caller has already - /// unlocked the CR. + /// raises SIZERR. `addr` MUST be quad-word aligned and reachable through the + /// band's alias. `band` selects the controller (SEC* or NS*), matching the + /// page's SECWM label (RM0456 Table 68). The caller has already unlocked the + /// matching CR. fn program_quad_word ( &mut self, + band: regs::PageBand, addr: u32, data: &[u8], ) @@ -222,14 +226,15 @@ where { return Err(FlashError::OutOfRange); } - self.wait_ready()?; - self.clear_errors(); + let sr = band.sr(); + let cr = band.cr(); + self.wait_ready_on(sr)?; + self.clear_errors_on(sr); // Set PG, then write the four words. A read of a fully-erased quad-word // is all-ones, so padding a short tail with the erased word leaves those // bytes untouched (program clears bits only, RM0456 sec 7.3.1). - self.access - .modify32(regs::FLASH_SECCR, 0, regs::SECCR_PG); + self.access.modify32(cr, 0, regs::SECCR_PG); let mut buf = [regs::ERASED_BYTE; regs::QUAD_WORD_LEN as usize]; let slot = buf @@ -253,11 +258,10 @@ where self.access.write32(word_addr, word); } - self.wait_ready()?; - let result = self.check_eop_then_clear_errors(); + self.wait_ready_on(sr)?; + let result = self.check_eop_then_clear_errors_on(sr); // Clear PG whatever happened, so the controller returns to idle. - self.access - .modify32(regs::FLASH_SECCR, regs::SECCR_PG, 0); + self.access.modify32(cr, regs::SECCR_PG, 0); result } @@ -265,25 +269,29 @@ where /// /// RM0456 sec 7.3.7 / 7.3.6: a successful op sets EOP. The driver treats a /// set error flag as the authority (fail closed) and clears EOP and the - /// error flags so the next op starts from a known SR. - fn check_eop_then_clear_errors(&mut self) -> Result<(), FlashError> + /// error flags so the next op starts from a known SR. `sr` is the band's + /// status register. + fn check_eop_then_clear_errors_on(&mut self, sr: u32) -> Result<(), FlashError> { - let errors = self.check_errors(); + let errors = self.check_errors_on(sr); // Clear EOP (rc_w1) regardless, so it does not leak into the next op. - self.access.write32(regs::FLASH_SECSR, regs::SR_EOP); + self.access.write32(sr, regs::SR_EOP); errors } - /// Erases one 8 KB page of the given physical bank. + /// Erases one 8 KB page of the given physical bank on the band's controller. /// /// RM0456 sec 7.3.6: poll ready, clear errors, write PER plus BKER plus PNB, - /// set STRT, poll BSY, check EOP, clear PER. The caller has unlocked the CR. - /// `page` is bank-relative (0..[`regs::PAGES_PER_BANK`]). BKER comes from the - /// physical bank (SWAP_BANK-independent, RM0456 sec 7.5.8). + /// set STRT, poll BSY, check EOP, clear PER. The caller has unlocked the + /// band's CR. `page` is bank-relative (0..[`regs::PAGES_PER_BANK`]). BKER + /// comes from the physical bank (SWAP_BANK-independent, RM0456 sec 7.5.8). + /// `band` selects the controller matching the page's SECWM label: a secure + /// controller erasing a non-secure page raises WRPERR (RM0456 Table 68). fn erase_page ( &mut self, bank: PhysBank, + band: regs::PageBand, page: u32, ) -> Result<(), FlashError> @@ -292,17 +300,19 @@ where { return Err(FlashError::OutOfRange); } - self.wait_ready()?; - self.clear_errors(); + let sr = band.sr(); + let cr = band.cr(); + self.wait_ready_on(sr)?; + self.clear_errors_on(sr); let bker = bank.bker(); let pnb = (page << regs::SECCR_PNB_SHIFT) & regs::SECCR_PNB_MASK; // Write PER plus BKER plus PNB in one word, first clearing every stale // operation-select bit so no mass-erase, burst-write, or interrupt - // request rides along (RM0456 sec 7.9.10), then set STRT in a second - // write (RM0456 sec 7.3.6). + // request rides along (RM0456 sec 7.9.9 / 7.9.10), then set STRT in a + // second write (RM0456 sec 7.3.6). self.access.modify32( - regs::FLASH_SECCR, + cr, regs::SECCR_PER | regs::SECCR_PG | regs::SECCR_PNB_MASK @@ -315,54 +325,122 @@ where | regs::SECCR_STRT, regs::SECCR_PER | bker | pnb, ); - self.access - .modify32(regs::FLASH_SECCR, 0, regs::SECCR_STRT); + self.access.modify32(cr, 0, regs::SECCR_STRT); - self.wait_ready()?; - let result = self.check_eop_then_clear_errors(); - self.access - .modify32(regs::FLASH_SECCR, regs::SECCR_PER, 0); + self.wait_ready_on(sr)?; + let result = self.check_eop_then_clear_errors_on(sr); + self.access.modify32(cr, regs::SECCR_PER, 0); result } - /// Maps a logical page index to its absolute address in the inactive bank. + /// Erases the bank-relative page range `[first, last)` on one band. /// - /// The machine writes `fw_update::PAGE_LEN`-byte logical pages. The address - /// is the inactive bank's mapped image-band base plus `page * PAGE_LEN`, - /// bounds-checked to stay inside the image band. Overflow-safe. The base is - /// the SAME physical bank the erase loop targets, so erase and program agree. + /// Unlocks the band's control register once, erases each page in the range + /// through the band's controller, then re-locks. RM0456 Table 68 rejects a + /// secure erase of a non-secure page (WRPERR), so the whole range MUST share + /// the `band`'s SECWM label. The image band is split at the SECWM boundary by + /// the two callers (secure pages 9-19, non-secure pages 20-31), so each call + /// is homogeneous. Fail-closed: a page-erase fault stops the loop, re-locks, + /// and returns the typed error, leaving the already-erased pages erased. + fn erase_band + ( + &mut self, + bank: PhysBank, + band: regs::PageBand, + first: u32, + last: u32, + ) + -> Result<(), FlashError> + { + self.unlock_cr_on(band.cr(), band.keyr()); + let mut result = Ok(()); + let mut page = first; + while page < last + { + if let Err(error) = self.erase_page(bank, band, page) + { + result = Err(error); + break; + } + page = match page.checked_add(1) + { + Some(next) => next, + None => + { + result = Err(FlashError::OutOfRange); + break; + } + }; + } + self.lock_cr_on(band.cr()); + result + } + + /// Maps a logical PAYLOAD page index to its band and absolute address in the + /// inactive bank. + /// + /// The machine writes `fw_update::PAGE_LEN`-byte payload pages across the + /// payload band (pages 10-31), page-aligned at the secure app link origin. + /// Page index 0 maps to physical page 10 (0x0C014000). The byte offset from + /// the payload base decides the page's [`regs::PageBand`]: an offset below the + /// secure payload size is a secure page (0x0C.. alias, SEC* controller), the + /// rest is a non-secure page (0x08.. alias, NS* controller). RM0456 Table 68 + /// forbids the secure controller from writing a non-secure page, so the band + /// routing is load-bearing. The descriptor page (page 9) is programmed separately + /// through [`Self::write_descriptor`]. + /// + /// A [`fw_update::PAGE_LEN`]-byte page never straddles the SECWM boundary: the + /// boundary is at payload offset [`regs::IMAGE_PAYLOAD_SECURE_SIZE`] (0x14000), + /// a multiple of `PAGE_LEN`, so each page lies wholly in one band. The alias + /// base is the same physical bank the erase loop targets, so erase and program + /// agree. Overflow-safe, bounds-checked to the payload band. fn logical_page_addr ( &mut self, page: PageIndex, ) - -> Result + -> Result<(regs::PageBand, u32), FlashError> { - let bank = self.inactive_phys(); - let base = self.phys_base(bank); - let image_base = base - .checked_add(regs::IMAGE_REGION_OFFSET) - .ok_or(FlashError::OutOfRange)?; let offset = (page as u32) .checked_mul(fw_update::PAGE_LEN as u32) .ok_or(FlashError::OutOfRange)?; let end = offset .checked_add(fw_update::PAGE_LEN as u32) .ok_or(FlashError::OutOfRange)?; - if end > regs::IMAGE_REGION_SIZE + if end > regs::IMAGE_PAYLOAD_SIZE { return Err(FlashError::OutOfRange); } - image_base.checked_add(offset).ok_or(FlashError::OutOfRange) + // Byte offset from the payload base. Below the secure payload size it is a + // secure page, at or above it a non-secure page. `end <= size` and the + // boundary is page-aligned, so the whole page shares one band. + let band = if offset < regs::IMAGE_PAYLOAD_SECURE_SIZE + { + regs::PageBand::Secure + } + else + { + regs::PageBand::NonSecure + }; + let bank = self.inactive_phys(); + let secure_base = self.phys_base(bank); + let alias_base = band.alias_base(secure_base); + let payload_base = alias_base + .checked_add(regs::IMAGE_PAYLOAD_OFFSET) + .ok_or(FlashError::OutOfRange)?; + let addr = payload_base + .checked_add(offset) + .ok_or(FlashError::OutOfRange)?; + Ok((band, addr)) } // Metadata helpers, pinned to PHYSICAL Bank 1, swap-aware. // // The NVCNT, boot-count, pending, and update-outcome records all live in - // physical Bank 1 (pages 0-1). The driver re-derives Bank 1's MAPPED base - // from the live SWAP_BANK on EVERY access, so the records survive a swap - // (RM0456 sec 7.5.8: data lives at a physical location mapped to different - // virtual addresses by SWAP_BANK). This is the B1 fix applied to metadata. + // physical Bank 1 (pages 0-1). The driver re-derives Bank 1's mapped base from the + // live SWAP_BANK on every access, so the records survive a swap (RM0456 sec 7.5.8: + // data lives at a physical location mapped to different virtual addresses by + // SWAP_BANK). This is the B1 fix applied to metadata. /// The live mapped base of a metadata record in physical Bank 1. fn meta_addr(&mut self, offset: u32) -> Result @@ -435,8 +513,10 @@ where /// Programs a single u32 record at `addr` (padded to a quad-word). /// - /// Unlocks the CR, programs the quad-word, then re-locks. Fail-closed: a - /// program fault re-locks and returns the typed error. + /// The metadata band is physical Bank 1 pages 0-1, always SECURE, so the + /// record is programmed on the secure controller through the secure alias. + /// Unlocks the secure CR, programs the quad-word, then re-locks. Fail-closed: + /// a program fault re-locks and returns the typed error. fn program_record ( &mut self, @@ -445,9 +525,13 @@ where ) -> Result<(), FlashError> { - self.unlock_cr(); - let result = self.program_quad_word(addr, &value.to_le_bytes()); - self.lock_cr(); + self.unlock_cr_on(regs::FLASH_SECCR, regs::FLASH_SECKEYR); + let result = self.program_quad_word( + regs::PageBand::Secure, + addr, + &value.to_le_bytes(), + ); + self.lock_cr_on(regs::FLASH_SECCR); result } @@ -458,14 +542,14 @@ where Ok(self.access.read32(addr)) } - /// Rewrites BOTH page-1 mutable records (pending and outcome) at once. + /// Rewrites both page-1 mutable records (pending and outcome) at once. /// /// The pending and update-outcome records share page 1 of physical Bank 1, so /// a rewrite of either erases the one page and reprograms both (RM0456 sec /// 7.3.6: erase is per 8 KB page). The caller supplies the desired post-write /// value of each record. An erased value programs nothing (an erased page /// already reads erased). Fail-closed: an erase or program fault re-locks and - /// returns the typed error, leaving the OLD records readable as best effort. + /// returns the typed error, leaving the old records readable as best effort. fn rewrite_mutable_records ( &mut self, @@ -475,9 +559,15 @@ where -> Result<(), FlashError> { self.require_dualbank_secure()?; - self.unlock_cr(); - let erased = self.erase_page(PhysBank::One, regs::META_MUTABLE_PAGE); - self.lock_cr(); + // Page 1 of physical Bank 1 is a SECURE metadata page, so the erase runs + // on the secure controller through the secure alias. + self.unlock_cr_on(regs::FLASH_SECCR, regs::FLASH_SECKEYR); + let erased = self.erase_page( + PhysBank::One, + regs::PageBand::Secure, + regs::META_MUTABLE_PAGE, + ); + self.lock_cr_on(regs::FLASH_SECCR); erased?; if pending_value != regs::PENDING_NONE { @@ -493,48 +583,171 @@ where } } +/// The running-bank read surface and the SECWM readback the boot stage consumes. +/// +/// The `fw_update::FlashSeam` impl below reads the inactive bank (the updater's +/// staging view, through the high alias). The boot stage instead verifies the bank +/// it is about to boot, so these accessors mirror the inactive-bank banded read but +/// resolve the running physical bank, which sits at the low alias. Each sub-band is +/// still read through the alias matching its SECWM label (RM0456 Table 68), so the +/// same-store property holds: the bytes verified are the bytes the hand-off boots. +impl Stm32FlashSeam +where + A: FlashAccess, +{ + /// Asserts the dual-bank secure posture (DUALBANK and TZEN set). + /// + /// # Errors + /// + /// [`FlashError::Hardware`] if the part is not dual-bank secure. + pub fn require_partition(&mut self) -> Result<(), FlashError> + { + self.require_dualbank_secure() + } + + /// Reads the two secure-watermark registers back (`FLASH_SECWM1R1` / + /// `FLASH_SECWM2R1`). + /// + /// Returns the raw register words for the caller to decode. Secure-read-only: + /// on a TZEN=0 part a non-secure read is RAZ, which the caller treats as a + /// mismatch. RM0456 sec 7.9.17 / 7.9.21. + /// + /// # Errors + /// + /// This read cannot fail on the real port, but the signature stays fallible so + /// a future access seam may report a fault. + pub fn read_secwm_raw(&mut self) -> Result<(u32, u32), FlashError> + { + let bank1 = self.access.read32(regs::FLASH_SECWM1R1); + let bank2 = self.access.read32(regs::FLASH_SECWM2R1); + Ok((bank1, bank2)) + } + + /// Borrows the running bank's image descriptor (page 9), read through the secure + /// alias. Header at [0:24], signature at [24:88]. + pub fn active_descriptor(&self) -> &[u8] + { + let swap = regs::swap_bank_set(self.access.peek32(regs::FLASH_OPTR)); + let bank = regs::running_phys_bank(swap); + let secure_base = bank.mapped_base(swap); + let descriptor_base = regs::PageBand::Secure + .alias_base(secure_base) + .wrapping_add(regs::IMAGE_DESCRIPTOR_OFFSET); + self.access + .bank_view(descriptor_base, regs::IMAGE_DESCRIPTOR_LEN as usize) + } + + /// Borrows the running bank's secure payload sub-band (pages 10-19), read through + /// the secure alias. + pub fn active_secure_band(&self) -> &[u8] + { + let swap = regs::swap_bank_set(self.access.peek32(regs::FLASH_OPTR)); + let bank = regs::running_phys_bank(swap); + let secure_base = bank.mapped_base(swap); + let band_base = regs::PageBand::Secure + .alias_base(secure_base) + .wrapping_add(regs::IMAGE_PAYLOAD_OFFSET); + self.access + .bank_view(band_base, regs::IMAGE_PAYLOAD_SECURE_SIZE as usize) + } + + /// Borrows the running bank's non-secure payload sub-band (pages 20-31), read + /// through the non-secure alias. + /// + /// RM0456 Table 68: reading a non-secure page through the secure alias returns + /// RAZ, so this band uses the non-secure alias. + pub fn active_ns_band(&self) -> &[u8] + { + let swap = regs::swap_bank_set(self.access.peek32(regs::FLASH_OPTR)); + let bank = regs::running_phys_bank(swap); + let secure_base = bank.mapped_base(swap); + let band_base = regs::PageBand::NonSecure + .alias_base(secure_base) + .wrapping_add(regs::IMAGE_NS_BAND_OFFSET); + self.access + .bank_view(band_base, regs::IMAGE_NS_BAND_SIZE as usize) + } +} + impl FlashSeam for Stm32FlashSeam where A: FlashAccess, { - fn inactive_bank(&self) -> &[u8] - { - // Verify must read the EXACT bytes commit boots. On real silicon the - // inactive bank is memory-mapped, so this borrows its image band with no - // copy. The inactive physical bank and its mapped base both come from the - // live OPTR.SWAP_BANK through a shared `peek32` (RM0456 sec 7.9.13), then - // the image band is borrowed through `bank_view`. The host model returns - // a borrow of its own backing bytes for the same region, so verify and - // commit act on one store. + fn inactive_descriptor(&self) -> &[u8] + { + // The image descriptor (page 9) of the inactive bank, read through the secure + // alias (0x0C..). It holds the signed image's header at [0:24] and its + // signature at [24:88]. Page 9 is a secure page, so the descriptor is read + // through the secure alias, the store the commit boots. + let swap = regs::swap_bank_set(self.access.peek32(regs::FLASH_OPTR)); + let bank = regs::inactive_phys_bank(swap); + let secure_base = bank.mapped_base(swap); + let descriptor_base = regs::PageBand::Secure + .alias_base(secure_base) + .wrapping_add(regs::IMAGE_DESCRIPTOR_OFFSET); + self.access + .bank_view(descriptor_base, regs::IMAGE_DESCRIPTOR_LEN as usize) + } + + fn inactive_secure_band(&self) -> &[u8] + { + // The secure payload sub-band (pages 10-19) of the inactive bank, read + // through the secure alias (0x0C..). RM0456 Table 68: a secure page must be + // read through the secure alias, so this band is homogeneous secure. On real + // silicon the inactive bank is memory-mapped, so this borrows the band with no + // copy. The host model returns a borrow of its own backing bytes, so verify + // reads the exact bytes commit boots. + let swap = regs::swap_bank_set(self.access.peek32(regs::FLASH_OPTR)); + let bank = regs::inactive_phys_bank(swap); + let secure_base = bank.mapped_base(swap); + let band_base = regs::PageBand::Secure + .alias_base(secure_base) + .wrapping_add(regs::IMAGE_PAYLOAD_OFFSET); + self.access + .bank_view(band_base, regs::IMAGE_PAYLOAD_SECURE_SIZE as usize) + } + + fn inactive_ns_band(&self) -> &[u8] + { + // The non-secure image sub-band (pages 20-31) of the inactive bank, read + // through the non-secure alias (0x08..). RM0456 Table 68: reading a non-secure + // page through the secure alias returns RAZ (all zeros), so this band must use + // the NS alias or verify would see zeros for the whole non-secure half. The + // verify / commit same-store property holds: this is still the store the + // commit boots, read through the correct alias. let swap = regs::swap_bank_set(self.access.peek32(regs::FLASH_OPTR)); let bank = regs::inactive_phys_bank(swap); - let base = bank.mapped_base(swap); - let image_base = base.wrapping_add(regs::IMAGE_REGION_OFFSET); + let secure_base = bank.mapped_base(swap); + let band_base = regs::PageBand::NonSecure + .alias_base(secure_base) + .wrapping_add(regs::IMAGE_NS_BAND_OFFSET); self.access - .bank_view(image_base, regs::IMAGE_REGION_SIZE as usize) + .bank_view(band_base, regs::IMAGE_NS_BAND_SIZE as usize) } fn erase_inactive(&mut self) -> Result<(), FlashError> { self.require_dualbank_secure()?; let bank = self.inactive_phys(); - self.unlock_cr(); - let mut result = Ok(()); - // Erase only the image pages of the inactive bank. The metadata band is - // pages 0-1 of physical Bank 1, never an image page, so this loop never - // erases NVCNT, the pending record, the boot-count, or the outcome. The - // image pages are the SAME physical bank the program path writes, so - // erase and program agree. - for page in regs::IMAGE_PAGE_FIRST..regs::PAGES_PER_BANK - { - if let Err(error) = self.erase_page(bank, page) - { - result = Err(error); - break; - } - } - self.lock_cr(); - result + // Erase only the image pages (9-31) of the inactive bank. The metadata band + // (pages 0-1) and the immutable boot stage (pages 2-8) are below + // IMAGE_PAGE_FIRST, so this loop never erases NVCNT, the boot stage, or any + // record. The secure sub-band (pages 9-19) is erased on the secure + // controller, the non-secure sub-band (pages 20-31) on the non-secure + // controller: RM0456 Table 68 rejects a secure erase of a non-secure page + // with WRPERR, so each page uses the controller matching its SECWM band. + self.erase_band( + bank, + regs::PageBand::Secure, + regs::IMAGE_PAGE_FIRST, + regs::IMAGE_NS_PAGE_FIRST, + )?; + self.erase_band( + bank, + regs::PageBand::NonSecure, + regs::IMAGE_NS_PAGE_FIRST, + regs::PAGES_PER_BANK, + ) } fn write_inactive_page @@ -550,8 +763,10 @@ where return Err(FlashError::OutOfRange); } self.require_dualbank_secure()?; - let base = self.logical_page_addr(page)?; - self.unlock_cr(); + // The logical page lies wholly in one band (the boundary is page-aligned), + // so it is programmed on that band's controller through that band's alias. + let (band, base) = self.logical_page_addr(page)?; + self.unlock_cr_on(band.cr(), band.keyr()); let mut result = Ok(()); // A logical page is many quad-words. Program it quad-word by quad-word // at the right absolute address. A short final quad-word is padded with @@ -582,14 +797,73 @@ where break; } }; - if let Err(error) = self.program_quad_word(addr, chunk) + if let Err(error) = self.program_quad_word(band, addr, chunk) + { + result = Err(error); + break; + } + done += take; + } + self.lock_cr_on(band.cr()); + result + } + + fn write_descriptor(&mut self, descriptor: &[u8]) -> Result<(), FlashError> + { + if descriptor.len() > regs::PAGE_SIZE as usize + { + return Err(FlashError::OutOfRange); + } + self.require_dualbank_secure()?; + // The descriptor is page 9 of the inactive bank, a SECURE page, so it is + // programmed on the secure controller through the secure alias. erase_ + // inactive already erased page 9, so this single programming pass writes + // the header and signature without a reprogram (no PROGERR). + let bank = self.inactive_phys(); + let secure_base = self.phys_base(bank); + let base = regs::PageBand::Secure + .alias_base(secure_base) + .checked_add(regs::IMAGE_DESCRIPTOR_OFFSET) + .ok_or(FlashError::OutOfRange)?; + self.unlock_cr_on(regs::FLASH_SECCR, regs::FLASH_SECKEYR); + let mut result = Ok(()); + // The descriptor is many quad-words. Program it quad-word by quad-word. A + // short final quad-word is padded with the erased value inside + // program_quad_word, so the trailing bytes never raise SIZERR. + let mut done = 0usize; + while done < descriptor.len() + { + let take = core::cmp::min( + regs::QUAD_WORD_LEN as usize, + descriptor.len() - done, + ); + let chunk = match descriptor.get(done..done + take) + { + Some(slice) => slice, + None => + { + result = Err(FlashError::OutOfRange); + break; + } + }; + let addr = match base.checked_add(done as u32) + { + Some(value) => value, + None => + { + result = Err(FlashError::OutOfRange); + break; + } + }; + if let Err(error) = + self.program_quad_word(regs::PageBand::Secure, addr, chunk) { result = Err(error); break; } done += take; } - self.lock_cr(); + self.lock_cr_on(regs::FLASH_SECCR); result } @@ -615,14 +889,13 @@ where fn commit_swap(&mut self) -> Result<(), FlashError> { - // INERT brick-class path. This carries the FULL real option-byte - // sequence (RM0456 sec 7.4.2): unlock the CR, unlock the options, flip - // OPTR.SWAP_BANK, set OPTSTRT, poll BSY, then set OBL_LAUNCH which - // RESETS the part and applies the option load on real silicon. The - // option-byte / OBL_LAUNCH writes are emitted ONLY through the - // target-gated MMIO port, which does not compile on the host. No host - // build and no test drives this on silicon. Its on-silicon invocation - // stays gated on a deliberate operator action plus the hardware + // Inert brick-class path. This carries the full real option-byte sequence + // (RM0456 sec 7.4.2): unlock the CR, unlock the options, flip OPTR.SWAP_BANK, + // set OPTSTRT, poll BSY, then set OBL_LAUNCH which resets the part and applies + // the option load on real silicon. The option-byte / OBL_LAUNCH writes are + // emitted only through the target-gated MMIO port, which does not compile on + // the host. No host build and no test drives this on silicon. Its on-silicon + // invocation stays gated on a deliberate operator action plus the hardware // power-fault proof. self.require_dualbank_secure()?; let target_is_bank2 = @@ -632,28 +905,20 @@ where fn revert_swap(&mut self) -> Result<(), FlashError> { - // INERT brick-class path, same real sequence as commit_swap but arming - // the swap back to the previously-running (now inactive) bank. Same - // gating: emitted only through the target-gated MMIO port, never - // auto-run on silicon. + // Inert brick-class path, same real sequence as commit_swap but arming the + // swap back to the previously-running (now inactive) bank. Same gating: + // emitted only through the target-gated MMIO port, never auto-run on silicon. self.require_dualbank_secure()?; - // Revert is reachable only after the forward swap already took effect - // and the NEW bank is running, so a correct revert flips SWAP_BANK BACK - // toward the previously-running bank, which is now the INACTIVE one - // (RM0456 sec 7.5.8). Arm toward the inactive bank, exactly the notion - // commit_swap arms toward, so the revert points the boot map back at the - // old image. + // Revert is reachable only after the forward swap already took effect and the + // new bank is running, so a correct revert flips SWAP_BANK back toward the + // previously-running bank, which is now the inactive one (RM0456 sec 7.5.8). + // Arm toward the inactive bank, exactly the notion commit_swap arms toward, so + // the revert points the boot map back at the old image. let swap = self.swap_bank(); let revert_target = regs::inactive_phys_bank(swap); - // Local restatement of the caller's contract: a revert points the boot - // map at a DIFFERENT bank than the one running, never at the live bank. - // The forward swap must already be in effect for revert to be correct, - // which means the revert target (the inactive bank) is distinct from the - // running bank (RM0456 sec 7.5.8). Compiled out of release builds. - debug_assert!( - revert_target != regs::running_phys_bank(swap), - "revert must arm toward a bank other than the running one", - ); + // The revert target is distinct from the running bank by construction: + // inactive_phys_bank and running_phys_bank are pure opposites of the same + // swap bool (RM0456 sec 7.5.8), so no runtime guard can add anything here. let target_is_bank2 = matches!(revert_target, PhysBank::Two); self.arm_swap(target_is_bank2) } @@ -698,7 +963,7 @@ where regs::PENDING_ARMED_BANK1 => Ok(PendingFlag::Armed(BankId::Bank1)), regs::PENDING_ARMED_BANK2 => Ok(PendingFlag::Armed(BankId::Bank2)), // Any other value is a torn or corrupt record. Fail closed: treat it - // as no pending confirm, which keeps the OLD bank bootable. + // as no pending confirm, which keeps the old bank bootable. _ => Ok(PendingFlag::None), } } @@ -781,19 +1046,25 @@ impl Stm32FlashSeam where A: FlashAccess, { - /// Arms the option-byte SWAP_BANK plus OBL_LAUNCH sequence (INERT). + /// Arms the option-byte SWAP_BANK plus OBL_LAUNCH sequence (inert). + /// + /// RM0456 sec 7.4.2: poll BSY, unlock the CR, unlock the options, write OPTR (set + /// or clear SWAP_BANK), set OPTSTRT, poll BSY, set OBL_LAUNCH. The final + /// OBL_LAUNCH triggers the reset that reloads the option bytes on real silicon, + /// the brick-class step. Every register write here lands on the [`FlashAccess`] + /// port, which is the target-gated MMIO on hardware and a state model in tests. + /// The model stages the swap and applies it only at a modelled reset, so no real + /// OBL_LAUNCH ever fires off-target. /// - /// RM0456 sec 7.4.2: poll BSY, unlock the CR, unlock the options, write - /// OPTR (set or clear SWAP_BANK), set OPTSTRT, poll BSY, set OBL_LAUNCH. The - /// final OBL_LAUNCH triggers the reset that reloads the option bytes on real - /// silicon, which is the brick-class step. Every register write here lands - /// on the [`FlashAccess`] port, which is the target-gated MMIO on hardware - /// and a state model in tests. The model stages the swap and applies it only - /// at a modelled reset, so NO real OBL_LAUNCH ever fires off-target. + /// The whole sequence drives the non-secure controller (OPTSTRT / OBL_LAUNCH live + /// in NSCR, RM0456 sec 7.4.2), so it polls FLASH_NSSR: the controller driven is + /// the controller polled. BSY is mirrored in both status registers (RM0456 sec + /// 7.3.5), so the readiness is the same, this only removes the controller / status + /// asymmetry on the brick-class path. fn arm_swap(&mut self, want_bank2: bool) -> Result<(), FlashError> { - self.wait_ready()?; - // The option program goes through the NON-SECURE control register + self.wait_ready_on(regs::FLASH_NSSR)?; + // The option program goes through the non-secure control register // (OPTSTRT / OBL_LAUNCH live in NSCR, RM0456 sec 7.4.2), so unlock the // NS CR with the same KEY1 / KEY2 pair, then unlock the options. self.unlock_ns_cr(); @@ -811,24 +1082,24 @@ where .modify32(regs::FLASH_OPTR, regs::OPTR_SWAP_BANK, 0); } - // Start the option program, then wait for BSY to clear. + // Start the option program, then wait for BSY to clear on the NS status. self.access .modify32(regs::FLASH_NSCR, 0, regs::NSCR_OPTSTRT); - self.wait_ready()?; + self.wait_ready_on(regs::FLASH_NSSR)?; // A rejected option program raises OPTWERR in NSSR (RM0456 sec 7.9.7). let nssr = self.access.read32(regs::FLASH_NSSR); if nssr & regs::SR_OPTWERR != 0 { - // Clear the rc_w1 flag and fail closed, NO OBL_LAUNCH is issued. + // Clear the rc_w1 flag and fail closed, no OBL_LAUNCH is issued. self.access.write32(regs::FLASH_NSSR, regs::SR_OPTWERR); self.lock_options(); self.lock_ns_cr(); return Err(FlashError::Hardware); } - // OBL_LAUNCH applies the option load and RESETS the part on silicon. - // This is the inert brick-class write: present, never auto-run. + // OBL_LAUNCH applies the option load and resets the part on silicon. This is + // the inert brick-class write: present, never auto-run. self.access .modify32(regs::FLASH_NSCR, 0, regs::NSCR_OBL_LAUNCH); diff --git a/crates/mcu-flash/src/driver_tests.rs b/crates/mcu-flash/src/driver_tests.rs index 6374ba8..a589f6a 100644 --- a/crates/mcu-flash/src/driver_tests.rs +++ b/crates/mcu-flash/src/driver_tests.rs @@ -3,7 +3,7 @@ //! These drive [`Stm32FlashSeam`] directly against [`FlashModel`] and assert the //! driver emits the right register sequence (unlock to PG to write to poll to //! lock), decodes the error flags, maps logical pages to addresses, and fails -//! closed. The model enforces REAL controller semantics: a write to a flash +//! closed. The model enforces real controller semantics: a write to a flash //! address with PG clear is ignored, a wrong unlock key leaves the CR locked, a //! reprogram of a non-erased word raises PROGERR, a sub-quad-word program is //! padded so a short page never raises SIZERR. So a wrong sequence shows up as a @@ -41,9 +41,12 @@ fn erase_inactive_leaves_image_region_erased_and_relocks() { let mut up = fresh(); up.erase_inactive().expect("erase"); - // The whole inactive (Bank 2) image region reads erased. - let bank = up.inactive_bank(); - assert!(bank.iter().all(|byte| *byte == regs::ERASED_BYTE)); + // Both sub-bands of the inactive (Bank 2) image region read erased, each + // through its own alias (secure via 0x0C.., non-secure via 0x08..). + let secure = up.inactive_secure_band(); + assert!(secure.iter().all(|byte| *byte == regs::ERASED_BYTE)); + let ns = up.inactive_ns_band(); + assert!(ns.iter().all(|byte| *byte == regs::ERASED_BYTE)); // The driver re-locked the CR from a known state after the op. assert!(up.access().model_locked()); } @@ -61,12 +64,89 @@ fn write_then_read_back_round_trips_through_the_seam() *byte = (i as u8) | 0x80; } up.write_inactive_page(0, &data).expect("write page 0"); - let bank = up.inactive_bank(); + // Logical payload page 0 lands at the start of the secure payload sub-band + // (physical page 10), so read it back through the secure alias. + let bank = up.inactive_secure_band(); assert_eq!(&bank[..data.len()], &data[..], "round-trip"); // The bytes past the written page stay erased. assert!(bank[data.len()..fw_update::PAGE_LEN].iter().all(|b| *b == 0xFF)); } +#[test] +fn descriptor_writes_page_9_and_reads_back_through_the_secure_alias() +{ + // The descriptor lands on page 9 (the image band start), one page below the + // secure payload band. Writing it must not touch the payload band, and it + // reads back through the secure alias. + let mut up = fresh(); + up.erase_inactive().expect("erase"); + let mut descriptor = [0u8; 88]; + for (i, byte) in descriptor.iter_mut().enumerate() + { + *byte = (i as u8) | 0x80; + } + up.write_descriptor(&descriptor).expect("write descriptor"); + + let read = up.inactive_descriptor(); + assert_eq!(&read[..descriptor.len()], &descriptor[..], "descriptor round-trip"); + // The secure PAYLOAD band (page 10 onward) is a different page, still erased. + let payload = up.inactive_secure_band(); + assert!( + payload.iter().all(|byte| *byte == regs::ERASED_BYTE), + "the descriptor write did not touch the payload band" + ); + // The driver re-locked the CR from a known state after the op. + assert!(up.access().model_locked()); +} + +#[test] +fn active_descriptor_reads_the_running_bank_through_the_low_alias() +{ + // The boot stage verifies the running bank. Write a descriptor into the + // inactive bank (Bank 2 while running Bank 1), then commit and reset so that + // bank becomes active. The active read must then return those exact bytes + // through the low alias, proving the bytes verified are the bytes booted. + let mut up = fresh(); + up.erase_inactive().expect("erase"); + let mut descriptor = [0u8; 88]; + for (i, byte) in descriptor.iter_mut().enumerate() + { + *byte = (i as u8) | 0x80; + } + up.write_descriptor(&descriptor).expect("write descriptor"); + + // Before the swap the running bank (Bank 1) is still erased, so the active + // read does not see the new descriptor. This makes the post-swap check + // non-vacuous. + assert!( + up.active_descriptor() + .iter() + .all(|byte| *byte == regs::ERASED_BYTE), + "the running bank is erased before the swap" + ); + + up.commit_swap().expect("commit"); + up.access_mut().apply_reset(); + assert_eq!(up.running_bank().expect("running"), BankId::Bank2); + + let read = up.active_descriptor(); + assert_eq!( + &read[..descriptor.len()], + &descriptor[..], + "the active read returns the running bank's descriptor after the swap" + ); +} + +#[test] +fn read_secwm_raw_reads_both_watermark_registers() +{ + // The default model shadows no watermark, so both read back zero. The read + // must not fault, and the boot stage treats an unprovisioned zero readback as + // a mismatch (a fail-closed discriminator). + let mut up = fresh(); + assert_eq!(up.read_secwm_raw().expect("read secwm"), (0, 0)); +} + #[test] fn write_without_a_prior_erase_fails_closed_on_progerr() { @@ -85,10 +165,11 @@ fn write_without_a_prior_erase_fails_closed_on_progerr() fn write_protected_page_fails_closed_on_wrperr() { let mut model = FlashModel::new(); - // The driver writes the inactive bank (physical Bank 2 here). The image band - // starts at physical page IMAGE_PAGE_FIRST, so logical page 0 lands on that - // physical page. Protect it to drive a WRPERR on the first image write. - model.protect_bank2_page(regs::IMAGE_PAGE_FIRST); + // The driver writes the inactive bank (physical Bank 2 here). The payload band + // starts at physical page IMAGE_PAYLOAD_PAGE_FIRST, so logical payload page 0 + // lands on that physical page. Protect it to drive a WRPERR on the first + // payload write. + model.protect_bank2_page(regs::IMAGE_PAYLOAD_PAGE_FIRST); let mut up = Stm32FlashSeam::new(model); up.erase_inactive().ok(); let result = up.write_inactive_page(0, &[0x00; 16]); @@ -100,9 +181,9 @@ fn logical_page_past_the_image_region_is_out_of_range() { let mut up = fresh(); up.erase_inactive().expect("erase"); - // The image region is 29 pages of 8 KB. PAGE_LEN is 256 bytes, so the last - // valid logical page is just under IMAGE_REGION_SIZE / PAGE_LEN. - let last_valid = (regs::IMAGE_REGION_SIZE / fw_update::PAGE_LEN as u32) - 1; + // The payload band is 22 pages of 8 KB. PAGE_LEN is 256 bytes, so the last + // valid logical payload page is just under IMAGE_PAYLOAD_SIZE / PAGE_LEN. + let last_valid = (regs::IMAGE_PAYLOAD_SIZE / fw_update::PAGE_LEN as u32) - 1; up.write_inactive_page(last_valid as u16, &[0xAA; 16]) .expect("last valid page"); let one_past = last_valid + 1; @@ -199,11 +280,11 @@ fn pending_and_outcome_records_are_independent() #[test] fn metadata_reads_from_physical_bank1_after_a_swap() { - // The B1 proof at the driver level: NVCNT, the pending record, and the - // outcome record are pinned to PHYSICAL Bank 1, addressed through the - // SWAP_BANK-aware helper. After a swap, physical Bank 1 sits at the HIGH - // alias, so a driver that used a fixed low-alias address would read the WRONG - // physical bank. This asserts the records read back unchanged after the swap. + // The B1 proof at the driver level: NVCNT, the pending record, and the outcome + // record are pinned to physical Bank 1, addressed through the SWAP_BANK-aware + // helper. After a swap, physical Bank 1 sits at the high alias, so a driver that + // used a fixed low-alias address would read the wrong physical bank. This asserts + // the records read back unchanged after the swap. let mut up = fresh(); up.nvcnt_bump(11).expect("bump nvcnt"); up.pending_write(PendingFlag::Armed(BankId::Bank2)) @@ -216,7 +297,7 @@ fn metadata_reads_from_physical_bank1_after_a_swap() up.commit_swap().expect("commit"); up.access_mut().apply_reset(); - // After the swap the SAME physical Bank 1 metadata reads back unchanged. + // After the swap the same physical Bank 1 metadata reads back unchanged. assert_eq!(up.nvcnt_read().expect("read"), 11, "NVCNT survives the swap"); assert_eq!( up.pending_read().expect("read"), @@ -240,13 +321,13 @@ fn metadata_reads_from_physical_bank1_after_a_swap() fn commit_swap_stages_the_swap_and_records_obl_launch_inert() { let mut up = fresh(); - // commit_swap carries the FULL real option-byte sequence. On the model it - // stages the swap and records the OBL_LAUNCH WITHOUT resetting, so the inert + // commit_swap carries the full real option-byte sequence. On the model it + // stages the swap and records the OBL_LAUNCH without resetting, so the inert // brick-class path is exercised without a real option load. up.commit_swap().expect("commit swap"); let model = up.access(); assert!(model.obl_launched(), "OBL_LAUNCH write observed"); - // The swap is STAGED, not yet applied: OPTR still boots Bank 1 until reset. + // The swap is staged, not yet applied: OPTR still boots Bank 1 until reset. assert_eq!(model.staged_swap(), Some(true), "staged toward Bank 2"); assert!(!model.boots_bank2(), "not applied before reset"); } @@ -254,16 +335,16 @@ fn commit_swap_stages_the_swap_and_records_obl_launch_inert() #[test] fn revert_after_commit_arms_the_swap_back_to_the_original_bank() { - // The revert-direction proof at the model level, across two modelled resets. - // A forward commit boots physical Bank 2, then a revert must point the boot - // map BACK at physical Bank 1 (the previously-running, now inactive bank, - // RM0456 sec 7.5.8), not re-arm toward the bank already running. The check - // also asserts the original bank's image bytes are still intact and bootable - // after the round trip, read PHYSICALLY so it does not depend on the alias. + // The revert-direction proof at the model level, across two modelled resets. A + // forward commit boots physical Bank 2, then a revert must point the boot map + // back at physical Bank 1 (the previously-running, now inactive bank, RM0456 sec + // 7.5.8), not re-arm toward the bank already running. The check also asserts the + // original bank's image bytes are still intact and bootable after the round trip, + // read physically so it does not depend on the alias. let mut model = FlashModel::new(); - // Seed physical Bank 1 (bank2 false) image band with a recognisable pattern, - // so the "original bank stays intact" claim is asserted against REAL backing - // bytes, not a rebuilt copy. + // Seed physical Bank 1 (bank2 false) image band with a recognisable pattern, so + // the "original bank stays intact" claim is asserted against real backing bytes, + // not a rebuilt copy. let pattern: [u8; 32] = core::array::from_fn(|i| (i as u8) | 0x80); for (i, byte) in pattern.iter().enumerate() { @@ -282,7 +363,7 @@ fn revert_after_commit_arms_the_swap_back_to_the_original_bank() assert!(up.access().boots_bank2(), "commit boots Bank 2"); assert_eq!(up.running_bank().expect("running"), BankId::Bank2); - // Revert, then the modelled reset: the boot map must flip BACK to physical + // Revert, then the modelled reset: the boot map must flip back to physical // Bank 1 (SWAP_BANK clear). A revert that re-armed toward the running bank // would leave SWAP_BANK set and keep the device on Bank 2. up.revert_swap().expect("revert"); @@ -291,7 +372,7 @@ fn revert_after_commit_arms_the_swap_back_to_the_original_bank() assert_eq!(up.running_bank().expect("running"), BankId::Bank1); // The original physical Bank 1 image bytes survived the round trip intact, - // read PHYSICALLY so the check is alias-independent. + // read physically so the check is alias-independent. for (i, byte) in pattern.iter().enumerate() { let offset = regs::IMAGE_REGION_OFFSET as usize + i; diff --git a/crates/mcu-flash/src/lib.rs b/crates/mcu-flash/src/lib.rs index 44001f3..9958c4d 100644 --- a/crates/mcu-flash/src/lib.rs +++ b/crates/mcu-flash/src/lib.rs @@ -12,37 +12,35 @@ //! //! The driver runs against [`FlashAccess`], a 32-bit register-access seam, so it //! is hardware-independent and host-testable. [`MmioFlash`] is the real -//! volatile-MMIO implementation (the crate's only `unsafe`, gated to the -//! embedded target). Host tests drive the driver over a faithful FLASH-controller -//! state model that holds TWO physical bank stores whose address-to-store mapping -//! flips on a modelled reset, and reproduces the BSY / WDW handshake, the rc_w1 -//! error flags, program-clears-bits, and the staged SWAP_BANK applied only at -//! that reset. So the silicon-only failure modes, including a metadata read from -//! the wrong physical bank after a swap, stay observable rather than hidden -//! behind a green host test. +//! volatile-MMIO implementation (the crate's sole `unsafe`, gated to the embedded +//! target). Host tests drive the driver over a faithful FLASH-controller state +//! model that holds two physical bank stores whose address-to-store mapping flips +//! on a modelled reset, and reproduces the BSY / WDW handshake, the rc_w1 error +//! flags, program-clears-bits, and the staged SWAP_BANK applied only at that reset. +//! So the silicon-only failure modes, including a metadata read from the wrong +//! physical bank after a swap, stay observable rather than hidden behind a green +//! host test. //! //! # Brick-safety: the option-byte path is present but inert //! //! The [`Stm32FlashSeam`] [`commit_swap`](fw_update::FlashSeam::commit_swap) and -//! [`revert_swap`](fw_update::FlashSeam::revert_swap) impls carry the -//! FULL real option-byte register sequence (OPTR SWAP_BANK plus OPTSTRT plus -//! OBL_LAUNCH, RM0456 sec 7.4.2). OBL_LAUNCH triggers the reset that applies the -//! option load on real silicon, which is the irreversible brick-class step. The -//! whole real register surface is the [`MmioFlash`] port, which does NOT compile -//! on the host. No host build and no test ever performs a real option-byte -//! write: the tests run the state model, which stages the swap and applies it -//! only at a modelled reset, never a real OBL_LAUNCH. The capability is complete -//! but inert. Its on-silicon invocation stays gated on a deliberate operator -//! action. +//! [`revert_swap`](fw_update::FlashSeam::revert_swap) impls carry the full real +//! option-byte register sequence (OPTR SWAP_BANK plus OPTSTRT plus OBL_LAUNCH, +//! RM0456 sec 7.4.2). OBL_LAUNCH triggers the reset that applies the option load on +//! real silicon, the irreversible brick-class step. The whole real register surface +//! is the [`MmioFlash`] port, which does not compile on the host. No host build and +//! no test ever performs a real option-byte write: the tests run the state model, +//! which stages the swap and applies it only at a modelled reset, never a real +//! OBL_LAUNCH. The capability is complete but inert. Its on-silicon invocation stays +//! gated on a deliberate operator action. //! //! # Register definitions //! -//! The registers, key values, and bank geometry are HAND-ROLLED and cited -//! (`regs`). Every address, bit, key value, and geometry -//! constant is pinned to a primary-source literal in the `regs` pinning tests. -//! The sources are RM0456 ch.7 (registers, sequences, geometry, the SWAP_BANK -//! physical-versus-mapped contract sec 7.5.8) and AN5347 Table 2 (the -//! secure-alias offset). +//! The registers, key values, and bank geometry are hand-rolled and cited (`regs`). +//! Every address, bit, key value, and geometry constant is pinned to a +//! primary-source literal in the `regs` pinning tests. The sources are RM0456 ch.7 +//! (registers, sequences, geometry, the SWAP_BANK physical-versus-mapped contract +//! sec 7.5.8) and AN5347 Table 2 (the secure-alias offset). #![cfg_attr(not(test), no_std)] @@ -59,6 +57,12 @@ mod driver_tests; #[cfg(test)] mod machine_tests; +#[cfg(test)] +mod power_fault_tests; + +#[cfg(test)] +mod mpu_containment_tests; + #[cfg(target_os = "none")] pub use crate::bus::MmioFlash; pub use crate::bus::FlashAccess; diff --git a/crates/mcu-flash/src/machine_tests.rs b/crates/mcu-flash/src/machine_tests.rs index ed835af..fab1ac4 100644 --- a/crates/mcu-flash/src/machine_tests.rs +++ b/crates/mcu-flash/src/machine_tests.rs @@ -1,20 +1,22 @@ -//! The dual-bank A/B update machine re-cabled onto the REAL driver. +//! The dual-bank A/B update machine re-cabled onto the real driver. //! //! This is the integration proof: the `fw-update` [`fw_update::Updater`] is -//! driven through its PUBLIC API (new, begin, receive_chunk, verify_and_accept, +//! driven through its public API (new, begin, receive_chunk, verify_and_accept, //! commit, on_boot, confirm) over [`Stm32FlashSeam`] backed by the faithful //! FLASH-controller model, instead of the in-crate mock. So the same machine the //! fw-update tests cover runs against the real register sequencing. //! //! A valid signed image is minted exactly as the fw-update tests do: the all-`01` -//! Ed25519 seed whose public key is [`fw_update::DEV_ROOT_KEY`], the header from -//! the `image-verify` `encode` feature, signed with `ed25519-dalek`. +//! P-256 private scalar, the header from the `image-verify` `encode` feature, and +//! an ECDSA P-256 signature normalized to low-s, the only encoding the verifier +//! accepts. The root key is derived from that scalar here rather than imported, so +//! this crate carries no copy of a key constant to drift. //! //! The model carries interior sharing ([`Shared`]) so the test keeps a handle to //! the backing flash after [`fw_update::Updater::new`] consumes the seam, and can -//! read NVCNT and the OLD-bank bytes back. The integration drives a full update, -//! models the swap reset, and asserts the NVCNT is read from the RIGHT PHYSICAL -//! bank after the swap (physical Bank 1 has moved to the high alias) and the OLD +//! read NVCNT and the old-bank bytes back. The integration drives a full update, +//! models the swap reset, and asserts the NVCNT is read from the right physical +//! bank after the swap (physical Bank 1 has moved to the high alias) and the old //! physical bank stays bootable, with no real option load ever firing. #![cfg(test)] @@ -25,16 +27,16 @@ use alloc::rc::Rc; use alloc::vec::Vec; use core::cell::RefCell; -use ed25519_dalek::Signer; -use ed25519_dalek::SigningKey; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; -use fw_update::DEV_ROOT_KEY; use fw_update::SeCounterError; use fw_update::SeCounterSeam; use fw_update::UpdateState; use fw_update::Updater; use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; use image_verify::RootKey; use image_verify::encode_header; use image_verify::verify_image; @@ -44,8 +46,10 @@ use crate::driver::Stm32FlashSeam; use crate::model::FlashModel; use crate::regs; -// The signing seed whose public key equals DEV_ROOT_KEY (the all-0x01 scalar). -const DEV_SEED: [u8; 32] = [1u8; 32]; +// The dev private scalar, test only. A publicly known, hardcoded key that makes +// every fixture deterministic. The all-0x01 value is a valid P-256 scalar: +// non-zero, and far below the curve order, which starts with 0xFF. +const DEV_SCALAR: [u8; 32] = [1u8; 32]; /// A shared handle to the FLASH-controller model. /// @@ -54,13 +58,13 @@ const DEV_SEED: [u8; 32] = [1u8; 32]; /// swap) after the updater owns the driver. `RefCell` gives the `&mut self` /// borrow each [`FlashAccess`] call needs from behind the shared handle. /// -/// # The `inactive_bank` borrow is structurally sound +/// # The inactive-band borrow is structurally sound /// /// [`FlashAccess::bank_view`] returns a slice the host analogue of memory-mapped /// flash, borrowed for `&self`. The trait signature ties the slice's lifetime to /// `&self`, so the borrow checker already forbids a `&mut self` access (the /// mutating [`FlashAccess::read32`] / [`FlashAccess::write32`]) while the slice is -/// live: the soundness does NOT rest on the `verify_and_accept` ordering, the +/// live: the soundness does not rest on the `verify_and_accept` ordering, the /// type system enforces it. /// /// The bytes live in the `Rc>`, in boxed bank arrays whose @@ -106,40 +110,37 @@ impl FlashAccess for Shared fn bank_view(&self, base: u32, len: usize) -> &[u8] { - // Resolve the alias `base` to a physical store through the SAME effective - // SWAP_BANK the model uses (RM0456 sec 7.5.8: the low alias is physical - // Bank 1 unless SWAP_BANK is set, the high alias the inverse), then return - // the equivalent borrow. The borrow is taken from a live `Ref` guard, so - // the pointer's provenance is checked against the RefCell state at this - // instant. The trait ties the returned slice to `&self`, which the borrow - // checker enforces, so a `&mut self` mutating access cannot run while the - // slice is live. + // Resolve the band read through the model (RM0456 sec 7.5.8 swap mapping + // plus RM0456 Table 68 RAZ on a wrong-alias read), then return the + // equivalent borrow. `band_ptr` yields either the physical store pointer + // (alias matches the page label) or the all-zero RAZ pointer (mismatch), + // both stable boxed arrays kept alive by the Rc. The borrow is taken from + // a live `Ref` guard, so the pointer is resolved against the RefCell state + // at this instant. The trait ties the returned slice to `&self`, which the + // borrow checker enforces, so a `&mut self` mutating access cannot run + // while the slice is live. let model = self.model.borrow(); - let swap = model.swap_bank(); - let (bank_a, bank_b, span) = model.store_ptrs(); - let (ptr, offset) = match resolve_alias(base, swap, span) + let (ptr, start, end) = match model.band_ptr(base, len) { - Some((false, off)) => (bank_a, off), - Some((true, off)) => (bank_b, off), + Some(triple) => triple, None => return &[], }; - let end = core::cmp::min(offset + len, span); - let start = core::cmp::min(offset, end); // Drop the Ref now that the stable store address is captured. The bytes - // outlive `&self` because the Rc keeps the model alive and each store is a - // boxed array whose address is stable for the model's life. + // outlive `&self` because the Rc keeps the model alive and each store (and + // the RAZ buffer) is a boxed array whose address is stable for the model's + // life. drop(model); - // SAFETY: this is a TEST double, the host analogue of memory-mapped flash, + // SAFETY: this is a test double, the host analogue of memory-mapped flash, // not the production MMIO port. `ptr` is one of the model's boxed bank - // arrays, kept alive by the Rc the test still holds, with a stable address - // for the model's life. The range `start..end` is clamped inside that - // array span, the bytes are plain `u8`. No aliasing arises in these - // tests: each `Shared` clone shares one `RefCell`, and the `Ref` guard is - // dropped before the slice is built, so the type system does NOT by - // itself bar a second clone from calling `borrow_mut` while a view is - // live. Safety here rests on usage, the returned slice is fully consumed - // before any other handle is touched, and the borrowed bytes are - // immutable flash during the verifying read. + // arrays or its RAZ buffer, kept alive by the Rc the test still holds, with + // a stable address for the model's life. The range `start..end` is clamped + // inside that array span by `band_ptr`, the bytes are plain `u8`. No + // aliasing arises in these tests: each `Shared` clone shares one `RefCell`, + // and the `Ref` guard is dropped before the slice is built, so the type + // system does not by itself bar a second clone from calling `borrow_mut` + // while a view is live. Safety here rests on usage, the returned slice is + // fully consumed before any other handle is touched, and the borrowed + // bytes are immutable flash during the verifying read. #[allow(unsafe_code)] unsafe { @@ -148,25 +149,6 @@ impl FlashAccess for Shared } } -/// Resolves an alias address to a physical store flag plus byte offset. -/// -/// Mirrors the model's resolver so the test double and the model agree on which -/// physical bytes an inactive-bank alias names (RM0456 sec 7.5.8). -fn resolve_alias(base: u32, swap: bool, span: usize) -> Option<(bool, usize)> -{ - if let Some(off) = base.checked_sub(regs::LOW_ALIAS_BASE) - && (off as usize) < span - { - return Some((swap, off as usize)); - } - if let Some(off) = base.checked_sub(regs::HIGH_ALIAS_BASE) - && (off as usize) < span - { - return Some((!swap, off as usize)); - } - None -} - /// A local secure-element counter double (no fuzz-feature dependency). /// /// Models the TROPIC01 MCounter abstractly: it counts DOWN from a provisioned @@ -202,9 +184,16 @@ impl SeCounterSeam for LocalSeCounter } } -// Builds a HEADER || payload || signature image signed with the DEV seed, +// The signing key of the dev scalar. +fn dev_signing_key() -> SigningKey +{ + SigningKey::from_slice(&DEV_SCALAR).expect("the dev scalar is in [1, n-1]") +} + +// Builds a HEADER || payload || signature image signed with the dev scalar, // carrying the given security counter, using the image-verify encode feature so -// the layout has a single source of truth. +// the layout has a single source of truth. The signature is normalized to low-s, +// the only encoding the verifier accepts. fn dev_image(security_counter: u32, payload: &[u8]) -> Vec { let version = ImageVersion @@ -218,16 +207,31 @@ fn dev_image(security_counter: u32, payload: &[u8]) -> Vec let mut signed = Vec::new(); signed.extend_from_slice(&header); signed.extend_from_slice(payload); - let sk = SigningKey::from_bytes(&DEV_SEED); - let sig = sk.sign(&signed); + let sig: p256::ecdsa::Signature = dev_signing_key().sign(&signed); + let sig = sig.normalize_s(); let mut image = signed; image.extend_from_slice(&sig.to_bytes()); image } +// The dev root key, derived from the dev scalar. Deriving it rather than pinning +// a second copy of the constant keeps this crate free of a key that could drift. fn dev_root() -> RootKey { - RootKey::from_bytes(DEV_ROOT_KEY).expect("dev root key is on-curve") + let point = dev_signing_key().verifying_key().to_sec1_point(false); + let mut bytes = [0u8; ROOT_KEY_LEN]; + bytes.copy_from_slice(point.as_ref()); + RootKey::from_bytes(bytes).expect("the derived dev root key is on-curve") +} + +// Verifies a contiguous image through the segmented verifier. This seam still +// hands back one contiguous slice, so a one-element segment list is exactly a +// contiguous image. +fn verify_contiguous(image: &[u8], root: &RootKey) -> Result<(), image_verify::VerifyError> +{ + let segments: [&[u8]; 1] = [image]; + verify_image(&segments, root)?; + Ok(()) } // Reads the NVCNT through a fresh driver over the shared model. @@ -239,10 +243,15 @@ fn read_nvcnt(shared: &Shared) -> u32 } #[test] -fn dev_seed_public_key_matches_dev_root_key() +fn the_dev_root_key_is_a_key_the_verifier_accepts() { - let sk = SigningKey::from_bytes(&DEV_SEED); - assert_eq!(sk.verifying_key().to_bytes(), DEV_ROOT_KEY); + // The derived key must be one RootKey::from_bytes accepts, so every fixture + // below verifies against a real pinned key rather than a rejected one. + let point = dev_signing_key().verifying_key().to_sec1_point(false); + assert_eq!(point.as_ref().len(), ROOT_KEY_LEN, "uncompressed SEC1, 65 bytes"); + let mut bytes = [0u8; ROOT_KEY_LEN]; + bytes.copy_from_slice(point.as_ref()); + assert!(RootKey::from_bytes(bytes).is_ok()); } #[test] @@ -250,9 +259,9 @@ fn full_update_over_the_real_driver_reads_nvcnt_from_right_bank_after_swap() { let shared = Shared::new(); - // Seed the OLD (running, physical Bank 1) bank image band with a complete - // valid v1 image so the "OLD bank stays bootable" invariant is asserted - // against real model bytes. The seeding is PHYSICAL (poke_phys on Bank 1), + // Seed the old (running, physical Bank 1) bank image band with a complete + // valid v1 image so the "old bank stays bootable" invariant is asserted + // against real model bytes. The seeding is physical (poke_phys on Bank 1), // bank-relative from the image-band offset, so it is independent of the alias // and survives the swap. let old_image = dev_image(3, b"old firmware payload v1"); @@ -276,7 +285,7 @@ fn full_update_over_the_real_driver_reads_nvcnt_from_right_bank_after_swap() let driver = Stm32FlashSeam::new(shared.clone()); let mut up = Updater::new(&root, driver, se); - // Stream a NEWER image (counter 7) through the public API into the inactive + // Stream a newer image (counter 7) through the public API into the inactive // (physical Bank 2) bank, in small chunks so the page accumulator is // exercised. let new_image = dev_image(7, b"new firmware payload v2 is a bit longer"); @@ -295,33 +304,33 @@ fn full_update_over_the_real_driver_reads_nvcnt_from_right_bank_after_swap() up.commit().expect("commit"); assert_eq!(up.state(), UpdateState::Committed); - // The commit only STAGED the swap (the inert option-byte path). No real + // The commit only staged the swap (the inert option-byte path). No real // option load fired: OPTR still boots Bank 1 until a modelled reset. assert!(shared.model.borrow().obl_launched(), "OBL_LAUNCH observed inert"); assert!(!shared.model.borrow().boots_bank2(), "swap not applied yet"); // Model the reset that the swap commits on (RM0456 sec 7.5.8): the staged // SWAP_BANK is applied, so the part now boots physical Bank 2 and physical - // Bank 1 (with the NVCNT) moves to the HIGH alias. + // Bank 1 (with the NVCNT) moves to the high alias. shared.model.borrow_mut().apply_reset(); assert!(shared.model.borrow().boots_bank2(), "swap applied at reset"); // First boot of the new bank: the running bank now matches the armed target. assert_eq!(up.on_boot().expect("boot"), UpdateState::AwaitingConfirm); - // Confirm: spends the SE counter, clears the record, bumps NVCNT LAST. + // Confirm: spends the SE counter, clears the record, bumps NVCNT last. up.confirm(7).expect("confirm"); assert_eq!(up.state(), UpdateState::Confirmed); - // The B1 PROOF: the NVCNT is read back from the RIGHT physical bank AFTER the + // The B1 proof: the NVCNT is read back from the right physical bank after the // swap. Physical Bank 1 now sits at the high alias, so a driver that used a // fixed low-alias metadata address would read physical Bank 2 (garbage). The // swap-aware helper re-derives Bank 1's high-alias address, so the counter // reads the value confirm just bumped. assert_eq!(read_nvcnt(&shared), 7, "nvcnt read from physical Bank 1 post-swap"); - // The OLD (now inactive, physical Bank 1) bank still holds its v1 image bytes - // and is independently verifiable, read PHYSICALLY so the check does not + // The old (now inactive, physical Bank 1) bank still holds its v1 image bytes + // and is independently verifiable, read physically so the check does not // depend on the current alias. let model = shared.model.borrow(); let mut recovered = Vec::new(); @@ -331,7 +340,7 @@ fn full_update_over_the_real_driver_reads_nvcnt_from_right_bank_after_swap() recovered.push(model.phys_byte(false, offset).expect("byte")); } assert_eq!(recovered, old_image, "OLD physical bank bytes intact"); - verify_image(&recovered, &root).expect("OLD bank still bootable"); + verify_contiguous(&recovered, &root).expect("OLD bank still bootable"); } #[test] @@ -357,6 +366,65 @@ fn rejected_image_over_the_real_driver_never_commits() assert!(!shared.model.borrow().obl_launched(), "no swap armed"); } +#[test] +fn ns_band_reads_correct_via_ns_alias_and_raz_via_secure_alias() +{ + use fw_update::FlashSeam; + + // TRAP 4 regression (RM0456 Table 68): a non-secure image page read through + // the secure alias returns RAZ (all zeros). The banded read must use the NS + // alias for the non-secure sub-band, or verify would see zeros for the whole + // non-secure half while the host model stayed green. This proves the model + // makes a wrong-alias read fail, and that the driver reads the right alias. + // + // If inactive_ns_band regressed to the secure alias, the first assertion here + // goes red: the NS band would read all zeros instead of the seeded pattern. + let shared = Shared::new(); + + // Seed a recognisable non-zero pattern into the NS image sub-band (pages + // 20-31) of the inactive bank (physical Bank 2), bank-relative so the seeding + // is alias-independent. + let ns_off = regs::IMAGE_NS_BAND_OFFSET as usize; + let pattern: [u8; 64] = core::array::from_fn(|i| (i as u8) | 0x80); + for (i, byte) in pattern.iter().enumerate() + { + shared.model.borrow_mut().poke_phys(true, ns_off + i, *byte); + } + + // Through the NS alias the driver reads the real seeded bytes. + let driver = Stm32FlashSeam::new(shared.clone()); + let ns_band = driver.inactive_ns_band(); + assert_eq!( + &ns_band[..pattern.len()], + &pattern[..], + "NS band reads the real bytes through the NS alias" + ); + + // The same physical bytes read through the secure alias are RAZ. Physical + // Bank 2 is inactive, so it sits at the high alias, and the NS sub-band read + // through the secure high alias returns zeros (Table 68). This is exactly the + // fault a flat address-to-store model could not observe. + let secure_wrong = regs::HIGH_ALIAS_BASE + regs::IMAGE_NS_BAND_OFFSET; + let via_secure = shared + .model + .borrow() + .bank_view(secure_wrong, pattern.len()) + .to_vec(); + assert_eq!(via_secure.len(), pattern.len(), "RAZ read keeps the length"); + assert!( + via_secure.iter().all(|byte| *byte == 0), + "NS band read through the secure alias is RAZ (all zeros)" + ); + + // At the word level too: the secure-alias load of an NS page is RAZ, the + // NS-alias load returns the seeded data. + let raz_word = shared.model.borrow_mut().read32(secure_wrong); + assert_eq!(raz_word, 0, "secure-alias word read of an NS page is RAZ"); + let ns_addr = regs::NS_HIGH_ALIAS_BASE + regs::IMAGE_NS_BAND_OFFSET; + let data_word = shared.model.borrow_mut().read32(ns_addr); + assert_ne!(data_word, 0, "NS-alias word read of the NS page returns data"); +} + // An SE counter value whose derived anti-rollback floor is zero, so Gate 2 does // not interfere with a test that exercises Gate 1 plus the signature. const SE_FLOOR_ZERO: u32 = fw_update::SE_COUNTER_ORIGIN; diff --git a/crates/mcu-flash/src/model.rs b/crates/mcu-flash/src/model.rs index dafca63..567ed57 100644 --- a/crates/mcu-flash/src/model.rs +++ b/crates/mcu-flash/src/model.rs @@ -1,33 +1,40 @@ //! A faithful host model of the STM32U545 FLASH controller, for host tests. //! -//! This implements [`FlashAccess`] by modelling the REAL controller state, not -//! a per-address value queue: a stateful peripheral needs a model of the hardware +//! This implements [`FlashAccess`] by modelling the real controller state, not a +//! per-address value queue: a stateful peripheral needs a model of the hardware //! state, or a silicon-only fault hides behind a green host test. It models: -//! - TWO physically separate bank stores (`bank_a` = physical Bank 1, `bank_b` +//! - two physically separate bank stores (`bank_a` = physical Bank 1, `bank_b` //! = physical Bank 2), each 256 KB, where a program clears bits only //! (`new = old AND data`) and an erase sets 0xFF (RM0456 sec 7.3.1), -//! - the ADDRESS-TO-STORE mapping that FLIPS with the effective SWAP_BANK +//! - the address-to-store mapping that flips with the effective SWAP_BANK //! (RM0456 sec 7.5.8): when SWAP_BANK is clear the low alias resolves to //! physical Bank 1 and the high alias to Bank 2, and the reverse when set. So -//! a fixed virtual address resolves to DIFFERENT physical bytes before and -//! after a swap, which is the fault class a flat one-store model could not -//! observe, -//! - the SECSR BSY / WDW handshake (BSY pulses busy for a few polls on each -//! program / erase, so the driver's poll loop is exercised), -//! - the rc_w1 error flags (a reprogram of a non-erased word raises PROGERR, a -//! write-protected page raises WRPERR), so the driver's fail-closed path is -//! observable, +//! a fixed virtual address resolves to different physical bytes before and +//! after a swap, the fault class a flat one-store model could not observe, +//! - the SECWM page security label and the per-alias access rules (RM0456 +//! Table 68): each page carries a label (pages 0..=`SECWM_PEND` secure, the +//! rest non-secure), and a secure-alias (0x0C..) access to a non-secure page +//! is RAZ on read and Write-Ignored plus WRPERR on program / erase, while a +//! non-secure-alias (0x08..) access to a secure page is the same. This is the +//! TRAP-4 fault: reading the inactive bank's non-secure image pages through +//! the secure alias silently returns zeros. The model makes a wrong-alias +//! read fail, which is the whole reason the model exists, +//! - both controller register banks (SEC* and NS*): the secure image sub-band +//! is driven through SECKEYR / SECSR / SECCR, the non-secure image sub-band +//! through NSKEYR / NSSR / NSCR (RM0456 sec 7.9.9 / 7.9.10). The BSY / WDW +//! handshake and the rc_w1 error flags are mirrored across both status +//! registers (RM0456 sec 7.3.5), //! - the CR / option unlock key sequences (a wrong value or order leaves the //! register locked, RM0456 sec 7.3.5 / 7.4.2), -//! - the staged SWAP_BANK option load, applied ONLY at a modelled reset +//! - the staged SWAP_BANK option load, applied only at a modelled reset //! ([`FlashModel::apply_reset`]), never on the OBL_LAUNCH write itself. This -//! is what keeps the brick-class path INERT on the host: the model stages +//! is what keeps the brick-class path inert on the host: the model stages //! the swap instead of resetting, so no test ever performs a real option //! load (RM0456 sec 7.5.8). //! -//! The model is the test double the integration test drives the real driver -//! over, so the driver's exact unlock to program to poll to lock sequencing and -//! its page-to-address math run against faithful silicon behaviour. +//! The model is the test double the integration test drives the real driver over, so +//! the driver's exact unlock to program to poll to lock sequencing and its +//! page-to-address math run against faithful silicon behaviour. #![cfg(test)] @@ -42,10 +49,14 @@ use crate::regs; /// One physical bank store size in bytes (256 KB). const BANK_BYTES: usize = regs::BANK_SIZE as usize; -/// Which physical bank store an address resolves to, plus the byte index inside -/// that store. +/// Which physical bank store an address resolves to, the byte index inside that +/// store, and the SECURITY VIEW of the alias the address came through. struct Resolved { + /// True when the alias is a SECURE alias (0x0C..), false for the non-secure + /// alias (0x08..). This is the access view Table 68 checks against the page + /// label. + alias_secure: bool, /// True when the address resolves to physical Bank 2 (`bank_b`). bank2: bool, /// The byte index inside the resolved bank store. @@ -56,9 +67,93 @@ struct Resolved /// /// A small non-zero value exercises the driver's BSY poll loop without slowing /// the tests. The op completes on the triggering write, then BSY reads busy for -/// this many SECSR reads, then clears. +/// this many status-register reads, then clears. const BUSY_POLLS: u32 = 2; +/// The poison byte a torn image quad-word reads back as. +/// +/// A torn quad-word write leaves contents not guaranteed (RM0456 sec 7.3.11) and +/// a real readback raises a double-bit ECC fault (RM0456 sec 7.3.2). The seam has +/// no fault path on the byte slice, so the model writes this poison value, which +/// the verifier rejects, so the old bank boots. +const POISON_BYTE: u8 = 0xA5; + +/// Where a single modelled power cut lands relative to a persistent mutation. +/// +/// The register-level power-fault harness arms a countdown over the persistent +/// flash operations the driver issues (a quad-word program, a page erase, an +/// option-byte stage). When the countdown reaches the armed op the mode decides +/// what the cut does. This drives the real driver code over the modelled +/// registers, the gap the retired seam-level fw-update harness could not close. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CutMode +{ + /// Power dies before the mutation lands. The op faults, the store unchanged. + BeforeMutation, + /// Power dies after the mutation lands. The op completes, then the next + /// persistent op faults (the machine never runs past the cut). + AfterMutation, + /// A program tears mid quad-word. For an image quad-word the target is + /// poisoned so the bank fails verify on readback (RM0456 sec 7.3.11 / 7.3.2), + /// then the op faults. A page erase, an option stage, and a single-word + /// metadata record have no partial quad-word, so this degrades to + /// [`CutMode::BeforeMutation`] there, matching the real flash granularity. + TornWrite, +} + +/// The plan the four word-writes of one quad-word share, so one cut decision +/// made on the first word governs the whole quad-word. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QwPlan +{ + /// Program every word of the quad-word normally. + Proceed, + /// Program every word, then die once the quad-word completes (After). + ProgramThenDie, + /// Suppress every word of the quad-word (dead silicon, or a torn tail). + Suppress, +} + +/// What one word-write of a quad-word program must do under the armed cut. +enum WordStep +{ + /// Program the word normally. + Program, + /// Suppress the word (no store change). + Suppress, + /// Fault before the word lands: raise PROGERR, leave the store unchanged. + FaultBefore, + /// Tear the quad-word: poison it to detectable corruption, raise PROGERR. + Tear, +} + +/// What one atomic persistent op (a page erase or an option stage) must do under +/// the armed cut. These ops have no partial quad-word, so a tear degrades to a +/// fault. +enum SimpleStep +{ + /// Perform the op normally. + Proceed, + /// Suppress the op (dead silicon, no store change). + Suppress, + /// Fault: leave the persistent state unchanged. + Fault, + /// Perform the op, then die (the next persistent op faults). + MutateThenDie, +} + +/// True when the bank-relative byte `index` falls in a SECURE page. +/// +/// RM0456 sec 7.9.17 / 7.9.21: pages 0..=`SECWM_PEND` are secure, the rest are +/// non-secure. Both banks carry the identical layout, so the label is a pure +/// function of the byte index inside a bank store. Routes through +/// [`regs::page_band`] so the SECWM boundary has a single source of truth. +fn page_label_secure(index: usize) -> bool +{ + let page = (index as u32) / regs::PAGE_SIZE; + matches!(regs::page_band(page), regs::PageBand::Secure) +} + /// A faithful host model of the FLASH controller and the staged-swap state. pub(crate) struct FlashModel { @@ -69,24 +164,28 @@ pub(crate) struct FlashModel bank_a: Box<[u8; BANK_BYTES]>, /// Physical Bank 2 backing bytes (program clears bits, erase sets 0xFF). bank_b: Box<[u8; BANK_BYTES]>, + /// An all-zero buffer the size of a bank store, returned by a wrong-alias + /// band read to model RAZ (RM0456 Table 68). It is never written. + zeros: Box<[u8; BANK_BYTES]>, /// The live SECCR control value (LOCK, PG, PER, PNB, BKER, STRT). seccr: u32, /// The live SECSR status value (BSY, WDW, error flags). secsr: u32, - /// The live NSSR status value (option-write error path). + /// The live NSSR status value (BSY, WDW, error flags, option-write error). nssr: u32, - /// The live NSCR control value (OPTLOCK, OPTSTRT, OBL_LAUNCH). + /// The live NSCR control value (LOCK, PG, PER, PNB, BKER, STRT, plus the + /// option-byte bits OPTLOCK, OPTSTRT, OBL_LAUNCH). nscr: u32, - /// The EFFECTIVE OPTR option value the running system sees (SWAP_BANK, + /// The effective OPTR option value the running system sees (SWAP_BANK, /// DUALBANK, TZEN, RDP). A read of FLASH_OPTR returns this. The effective /// SWAP_BANK changes only at a modelled reset, never on the option-program /// write itself, so the running bank stays stable mid-update. The effective - /// SWAP_BANK ALSO drives address resolution, so a swap actually remaps which + /// SWAP_BANK also drives address resolution, so a swap actually remaps which /// physical store an alias address hits. optr: u32, /// The OPTR program shadow: where an OPTR register write lands. /// - /// RM0456 sec 7.4.2: writing OPTR then setting OPTSTRT PROGRAMS the option + /// RM0456 sec 7.4.2: writing OPTR then setting OPTSTRT programs the option /// bytes, but the SWAP_BANK change only takes effect at the option reload /// (the OBL_LAUNCH reset). So the driver's OPTR write updates this shadow, /// OPTSTRT stages the shadow's SWAP_BANK, and the modelled reset applies it @@ -94,8 +193,10 @@ pub(crate) struct FlashModel optr_shadow: u32, /// Remaining BSY busy polls before the in-flight op reports ready. busy_polls: u32, - /// CR unlock progress: 0 locked, 1 saw KEY1, 2 unlocked. + /// Secure CR unlock progress: 0 locked, 1 saw KEY1, 2 unlocked. cr_key_step: u8, + /// Non-secure CR unlock progress: 0 locked, 1 saw KEY1, 2 unlocked. + ns_cr_key_step: u8, /// Option unlock progress: 0 locked, 1 saw OPTKEY1, 2 unlocked. opt_key_step: u8, /// The staged SWAP_BANK target, applied only at a modelled reset. @@ -111,6 +212,25 @@ pub(crate) struct FlashModel /// A write-protected page that raises WRPERR on a program or erase, as /// `(bank2, page)` in PHYSICAL coordinates. `None` means none. wrp_page: Option<(bool, u32)>, + /// The remaining persistent-op countdown until an armed power cut fires. + /// + /// `None` means no cut is armed, so the model behaves like clean silicon. A + /// cut walks every persistent flash op of the whole flow and survives a + /// modelled reset (a reset clears only the volatile state), so it can fire in + /// the post-reset confirm or revert path. + cut_countdown: Option, + /// The mode the armed cut fires in. + cut_mode: CutMode, + /// Set once a cut has fired: every later persistent op is suppressed, because + /// on real hardware the CPU is dead until the next reset (the reboot). + cut_dead: bool, + /// True once a cut fired anywhere in the current power cycle (test census). + cut_fired: bool, + /// The total persistent flash ops observed, so the harness can measure a + /// flow's length and enumerate a per-index census. + persistent_ops: u32, + /// The plan shared by the four word-writes of the quad-word being programmed. + qw_plan: Option<(u32, QwPlan)>, } impl FlashModel @@ -124,6 +244,7 @@ impl FlashModel { bank_a: erased_bank(), bank_b: erased_bank(), + zeros: zero_bank(), seccr: regs::SECCR_LOCK, secsr: 0, nssr: 0, @@ -134,13 +255,58 @@ impl FlashModel optr_shadow: regs::OPTR_DUALBANK | regs::OPTR_TZEN, busy_polls: 0, cr_key_step: 0, + ns_cr_key_step: 0, opt_key_step: 0, staged_swap: None, obl_launched: false, wrp_page: None, + cut_countdown: None, + cut_mode: CutMode::BeforeMutation, + cut_dead: false, + cut_fired: false, + persistent_ops: 0, + qw_plan: None, } } + /// Arms a single power cut at the `index`-th persistent flash op, in `mode`. + /// + /// `index` counts persistent flash ops (quad-word programs, page erases, and + /// option stages) from zero, over the WHOLE flow across the reset boundary. + /// The cut fires at most once. + pub(crate) fn arm_cut(&mut self, index: u32, mode: CutMode) + { + self.cut_countdown = Some(index); + self.cut_mode = mode; + self.cut_dead = false; + self.cut_fired = false; + } + + /// True once the armed cut fired anywhere in this power cycle. + pub(crate) fn cut_fired(&self) -> bool + { + self.cut_fired + } + + /// The total persistent flash ops observed so far (flow-length measurement). + pub(crate) fn persistent_ops(&self) -> u32 + { + self.persistent_ops + } + + /// Models a reboot where the staged option load did not commit. + /// + /// A power cut before the OBL_LAUNCH option reload keeps the old option bytes + /// (RM0456 sec 7.5.8), so the staged swap is lost and the old bank boots. Like + /// [`FlashModel::apply_reset`] it clears the volatile controller state and the + /// dead flag, but it does not apply the swap. The armed cut countdown rides + /// across, so a cut can still fire after this reboot. + pub(crate) fn reboot_without_option_load(&mut self) + { + self.staged_swap = None; + self.reset_volatile(); + } + /// Marks a PHYSICAL Bank-2 page write-protected so a program / erase of it /// raises WRPERR, exercising the driver's fail-closed path on the inactive /// bank. @@ -209,9 +375,9 @@ impl FlashModel /// Applies the staged option load atomically, modelling the swap reset. /// /// On a real reset the option load writes the staged SWAP_BANK into OPTR and - /// clears the stage (RM0456 sec 7.5.8). After this the SAME alias address - /// resolves to the OTHER physical store. A power cut before this keeps the - /// OLD OPTR, so the harness only calls this to model a clean reset boundary. + /// clears the stage (RM0456 sec 7.5.8). After this the same alias address + /// resolves to the other physical store. A power cut before this keeps the + /// old OPTR, so the harness only calls this to model a clean reset boundary. pub(crate) fn apply_reset(&mut self) { if let Some(set) = self.staged_swap.take() @@ -228,28 +394,198 @@ impl FlashModel // agree again. self.optr_shadow = self.optr; } - // A reset also re-locks the CR and the options and clears volatile - // status, just like a real POR / system reset. + self.reset_volatile(); + } + + /// Clears the volatile controller state a reset restores, preserving the + /// non-volatile stores and any armed cut countdown. + /// + /// A reset re-locks both CRs and the options, clears the volatile status, and + /// clears the dead flag (the CPU runs again on fresh silicon after a reset). + /// It never touches the two bank stores, the metadata, or the armed cut + /// countdown, which survive the reset just like real non-volatile flash. + fn reset_volatile(&mut self) + { self.seccr = regs::SECCR_LOCK; self.nscr = regs::NSCR_LOCK | regs::NSCR_OPTLOCK; self.secsr = 0; self.nssr = 0; self.busy_polls = 0; self.cr_key_step = 0; + self.ns_cr_key_step = 0; self.opt_key_step = 0; self.obl_launched = false; + self.cut_dead = false; + self.qw_plan = None; } - /// Stable raw pointers to both physical stores plus the per-store byte span. + /// Steps the cut countdown for one ATOMIC persistent op (erase or stage). /// - /// Used only by the shared-handle integration test double to return the - /// `FlashSeam::inactive_bank` borrow, the host analogue of memory-mapped - /// flash. The pointers are stable for the model's life and the caller clamps - /// any range inside `span`. The double consults the effective SWAP_BANK to - /// pick which store an alias borrow resolves to. - pub(crate) fn store_ptrs(&self) -> (*const u8, *const u8, usize) + /// An erase or an option stage has no partial quad-word, so a torn write + /// degrades to a plain fault (RM0456 sec 7.3.6 / 7.4.2, page / option + /// granularity). + fn cut_step_simple(&mut self) -> SimpleStep { - (self.bank_a.as_ptr(), self.bank_b.as_ptr(), BANK_BYTES) + if self.cut_dead + { + return SimpleStep::Suppress; + } + self.persistent_ops = self.persistent_ops.saturating_add(1); + match self.cut_countdown + { + Some(0) => + { + self.cut_fired = true; + self.cut_countdown = None; + self.cut_dead = true; + match self.cut_mode + { + CutMode::AfterMutation => SimpleStep::MutateThenDie, + // An erase or an option stage has no partial quad-word, so a + // torn write degrades to a plain fault with the record left + // unchanged. This is the same fail-closed floor documented at + // the metadata program point (a torn erase or option record + // reads back as the old value or a safe default). The upward + // bit-superset / ECC-fault residual noted there applies to any + // torn record and is handled OUTSIDE this content model. + CutMode::BeforeMutation | CutMode::TornWrite => SimpleStep::Fault, + } + } + Some(n) => + { + self.cut_countdown = Some(n - 1); + SimpleStep::Proceed + } + None => SimpleStep::Proceed, + } + } + + /// Steps the cut countdown for one word-write of a quad-word program. + /// + /// The tick fires on the quad-word's first word, and the four words share one + /// [`QwPlan`] so the decision is made once. `image_qw` is true when the target + /// lies in the A/B image band, the only place a torn quad-word poisons into + /// detectable corruption. + fn cut_step_word(&mut self, addr: u32, image_qw: bool) -> WordStep + { + let qw_base = addr & !(regs::QUAD_WORD_LEN - 1); + let last_word = qw_base + (regs::QUAD_WORD_LEN - 4); + if !addr.is_multiple_of(regs::QUAD_WORD_LEN) + { + // A continuation word: follow the plan set on the first word. + return match self.qw_plan + { + Some((base, QwPlan::Proceed)) if base == qw_base => WordStep::Program, + Some((base, QwPlan::ProgramThenDie)) if base == qw_base => + { + if addr == last_word + { + // The quad-word has fully landed, so the machine dies now. + self.cut_dead = true; + } + WordStep::Program + } + Some((base, QwPlan::Suppress)) if base == qw_base => WordStep::Suppress, + _ => WordStep::Program, + }; + } + // The first word of a quad-word: the tick point. + if self.cut_dead + { + self.qw_plan = Some((qw_base, QwPlan::Suppress)); + return WordStep::Suppress; + } + self.persistent_ops = self.persistent_ops.saturating_add(1); + match self.cut_countdown + { + Some(0) => + { + self.cut_fired = true; + self.cut_countdown = None; + match self.cut_mode + { + CutMode::BeforeMutation => + { + self.cut_dead = true; + self.qw_plan = Some((qw_base, QwPlan::Suppress)); + WordStep::FaultBefore + } + CutMode::AfterMutation => + { + // Program the whole quad-word, then die on its last word. + self.qw_plan = Some((qw_base, QwPlan::ProgramThenDie)); + WordStep::Program + } + CutMode::TornWrite => + { + self.cut_dead = true; + self.qw_plan = Some((qw_base, QwPlan::Suppress)); + if image_qw + { + WordStep::Tear + } + else + { + // No image quad-word to tear, so degrade to a fault. + WordStep::FaultBefore + } + } + } + } + Some(n) => + { + self.cut_countdown = Some(n - 1); + self.qw_plan = Some((qw_base, QwPlan::Proceed)); + WordStep::Program + } + None => + { + self.qw_plan = Some((qw_base, QwPlan::Proceed)); + WordStep::Program + } + } + } + + /// Poisons the whole quad-word containing bank-relative `index` in a store. + fn poison_quad_word(&mut self, bank2: bool, index: usize) + { + let qw = index - (index % regs::QUAD_WORD_LEN as usize); + let end = core::cmp::min(qw + regs::QUAD_WORD_LEN as usize, BANK_BYTES); + let store = self.store_mut(bank2); + if let Some(slot) = store.get_mut(qw..end) + { + for byte in slot.iter_mut() + { + *byte = POISON_BYTE; + } + } + } + + /// Resolves a band read to a stable pointer plus a clamped byte range, + /// modelling RAZ. + /// + /// Returns `(ptr, start, end)` where `ptr` is the resolved PHYSICAL store + /// when the alias view matches the page label, or the all-zero RAZ buffer + /// when it does not (RM0456 Table 68). A band is homogeneous (every page in + /// it shares one label), so the base page's label decides the whole read. + /// Used by the shared-handle integration double to return the + /// `FlashSeam::inactive_secure_band` / `inactive_ns_band` borrow, the host + /// analogue of memory-mapped flash. The pointers are stable for the model's + /// life and the caller consumes the slice before touching another handle. + pub(crate) fn band_ptr(&self, base: u32, len: usize) -> Option<(*const u8, usize, usize)> + { + let resolved = self.resolve(base)?; + let end = core::cmp::min(resolved.index + len, BANK_BYTES); + let start = core::cmp::min(resolved.index, end); + let ptr = if resolved.alias_secure == page_label_secure(resolved.index) + { + self.store(resolved.bank2).as_ptr() + } + else + { + self.zeros.as_ptr() + }; + Some((ptr, start, end)) } /// The effective SWAP_BANK bit, exposed so the shared-handle double resolves @@ -285,97 +621,192 @@ impl FlashModel } } - /// Resolves an absolute alias address to a physical store and byte index. + /// Resolves an absolute alias address to a physical store, byte index, and + /// the alias security view. /// /// RM0456 sec 7.5.8: the low alias resolves to physical Bank 1 when SWAP_BANK /// is clear and to physical Bank 2 when set, and the high alias is the - /// inverse. This is the model behaviour that makes the B1 fault class - /// observable: a fixed alias address points at different physical bytes - /// depending on the swap. + /// inverse. RM0456 sec 2.3 / AN5347 Table 2: the secure alias sits at 0x0C.. + /// and the non-secure alias at 0x08.., a fixed offset apart, addressing the + /// same physical bytes with different security views. Modelling both is what + /// makes the B1 swap fault and the Table-68 wrong-alias fault observable. fn resolve(&self, addr: u32) -> Option { let swap = self.swap_bank(); - let low_off = addr.checked_sub(regs::LOW_ALIAS_BASE); - if let Some(off) = low_off + // Secure low alias -> physical Bank 1 unless SWAP_BANK is set. + if let Some(off) = addr.checked_sub(regs::LOW_ALIAS_BASE) && (off as usize) < BANK_BYTES { - // The low alias holds physical Bank 1 unless SWAP_BANK is set. return Some(Resolved { + alias_secure: true, bank2: swap, index: off as usize, }); } - let high_off = addr.checked_sub(regs::HIGH_ALIAS_BASE)?; - if (high_off as usize) < BANK_BYTES + // Secure high alias -> physical Bank 2 unless SWAP_BANK is set. + if let Some(off) = addr.checked_sub(regs::HIGH_ALIAS_BASE) + && (off as usize) < BANK_BYTES { - // The high alias holds physical Bank 2 unless SWAP_BANK is set. return Some(Resolved { + alias_secure: true, bank2: !swap, - index: high_off as usize, + index: off as usize, + }); + } + // Non-secure low alias -> the same physical store as the secure low + // alias, viewed non-secure. + if let Some(off) = addr.checked_sub(regs::NS_LOW_ALIAS_BASE) + && (off as usize) < BANK_BYTES + { + return Some(Resolved + { + alias_secure: false, + bank2: swap, + index: off as usize, + }); + } + // Non-secure high alias -> the same physical store as the secure high + // alias, viewed non-secure. + if let Some(off) = addr.checked_sub(regs::NS_HIGH_ALIAS_BASE) + && (off as usize) < BANK_BYTES + { + return Some(Resolved + { + alias_secure: false, + bank2: !swap, + index: off as usize, }); } None } - /// True when `addr` falls inside either mapped bank alias. + /// True when `addr` falls inside any mapped bank alias (secure or NS). fn is_flash(&self, addr: u32) -> bool { self.resolve(addr).is_some() } + /// Raises a controller error flag on the status register matching the access + /// view (secure -> SECSR, non-secure -> NSSR). RM0456 sec 7.9.7 / 7.9.8. + fn set_error(&mut self, via_secure: bool, flag: u32) + { + if via_secure + { + self.secsr |= flag; + } + else + { + self.nssr |= flag; + } + } + /// Programs one 32-bit word, clearing bits only and raising the right flag. /// /// RM0456 sec 7.3.1 / 7.3.7: program clears bits (`new = old AND value`). A - /// reprogram of a word that is not fully erased raises PROGERR. A write to a - /// WRP page raises WRPERR. The op then sets BSY busy for a few polls and - /// raises EOP on completion. + /// reprogram of a word that is not fully erased raises PROGERR. RM0456 Table + /// 68: a cross-label access (a secure-alias write to a non-secure page or the + /// reverse) is Write-Ignored and raises WRPERR on the accessing controller. + /// A write to a WRP page raises WRPERR. The op then sets BSY busy for a few + /// polls and raises EOP on completion. fn program_word(&mut self, addr: u32, value: u32) { + let resolved = match self.resolve(addr) + { + Some(resolved) => resolved, + // A non-flash address never reaches here (the caller gates on + // is_flash), so treat it as a sequence error defensively. + None => return, + }; + let via_secure = resolved.alias_secure; if addr & 0x3 != 0 { - self.secsr |= regs::SR_PGAERR; + self.set_error(via_secure, regs::SR_PGAERR); return; } - let resolved = match self.resolve(addr) + // Table 68: the alias view must match the page label, or the write is + // ignored and WRPERR is raised on the accessing controller. This is the + // fault a wrong-controller program would trip. + if via_secure != page_label_secure(resolved.index) { - Some(resolved) => resolved, - None => - { - self.secsr |= regs::SR_PGAERR; - return; - } - }; + self.set_error(via_secure, regs::SR_WRPERR); + return; + } if self.wrp_hit(resolved.bank2, resolved.index) { - self.secsr |= regs::SR_WRPERR; + self.set_error(via_secure, regs::SR_WRPERR); return; } - let store = self.store_mut(resolved.bank2); - let slot = match store.get_mut(resolved.index..resolved.index + 4) - { - Some(slot) => slot, - None => + let old = { + let store = self.store(resolved.bank2); + match store.get(resolved.index..resolved.index + 4) { - self.secsr |= regs::SR_PGAERR; - return; + Some(slot) => + { + let mut current = [0u8; 4]; + current.copy_from_slice(slot); + u32::from_le_bytes(current) + } + None => + { + self.set_error(via_secure, regs::SR_PGAERR); + return; + } } }; - let mut current = [0u8; 4]; - current.copy_from_slice(slot); - let old = u32::from_le_bytes(current); // RM0456 sec 7.3.7: PROGERR is set when the word to program is not - // previously erased, EXCEPT when the value written is all-zero. So a + // previously erased, except when the value written is all-zero. So a // reprogram of a non-erased word with any non-zero value fails closed. if old != regs::ERASED_WORD && value != 0 { - self.secsr |= regs::SR_PROGERR; + self.set_error(via_secure, regs::SR_PROGERR); return; } - let programmed = (old & value).to_le_bytes(); - slot.copy_from_slice(&programmed); - self.start_busy(); + // Only an image-band quad-word tears into detectable corruption. A torn + // program of a metadata record (NVCNT log, pending) degrades to a plain + // fault with the record left unchanged, this model's fail-closed floor: a + // torn metadata or option record reads back as the old value or a safe + // default, and the record constants are chosen non-bit-superset, so a torn + // program (which only clears bits, RM0456 sec 7.3.7) can never flip one valid + // record into another valid record. + // + // Residual not modelled here: on real silicon a torn NVCNT quad-word can read + // back as a high bit-superset value, poisoning the monotone floor upward, or + // raise a double-bit ECC fault on the next read. That is an availability / + // brick-adjacent risk that lives in the ECC-fault-handling layer, outside this + // content model, and must be handled there. This model covers only the content + // a successful read returns, not the ECC fault a torn ECC quad-word can raise. + let image_qw = resolved.index >= regs::IMAGE_REGION_OFFSET as usize; + match self.cut_step_word(addr, image_qw) + { + WordStep::Program => + { + let programmed = (old & value).to_le_bytes(); + if let Some(slot) = + self.store_mut(resolved.bank2).get_mut(resolved.index..resolved.index + 4) + { + slot.copy_from_slice(&programmed); + } + self.start_busy(via_secure); + } + // A dead or suppressed word never lands, and never faults: the machine + // simply did not run this write on real hardware. + WordStep::Suppress => + {} + // A power cut before this word lands fails the op closed (PROGERR). + WordStep::FaultBefore => + { + self.set_error(via_secure, regs::SR_PROGERR); + } + // A torn image quad-word reads back as detectable corruption, then the + // op fails closed (PROGERR). + WordStep::Tear => + { + self.poison_quad_word(resolved.bank2, resolved.index); + self.set_error(via_secure, regs::SR_PROGERR); + } + } } /// True when a physical `(bank2, page)` coordinate is write-protected. @@ -385,62 +816,119 @@ impl FlashModel self.wrp_page == Some((bank2, page)) } - /// Erases the page selected by the live SECCR (PER plus BKER plus PNB). + /// Erases the page selected by a control register (PER plus BKER plus PNB). /// /// RM0456 sec 7.3.6: the page erase sets every byte of the selected 8 KB /// PHYSICAL page to 0xFF. BKER names the physical bank directly, with no - /// SWAP_BANK correction (RM0456 sec 7.5.8). A WRP page raises WRPERR instead. - fn erase_selected_page(&mut self) + /// SWAP_BANK correction (RM0456 sec 7.5.8). RM0456 Table 68: a cross-label + /// erase (the secure controller erasing a non-secure page or the reverse) is + /// ignored and raises WRPERR on the accessing controller. A WRP page raises + /// WRPERR too. `via_secure` is the controller: true for SEC*, false for NS*. + fn erase_page_from_cr(&mut self, via_secure: bool, cr_value: u32) { - let pnb = (self.seccr & regs::SECCR_PNB_MASK) >> regs::SECCR_PNB_SHIFT; - let bank2 = self.seccr & regs::SECCR_BKER != 0; + let pnb = (cr_value & regs::SECCR_PNB_MASK) >> regs::SECCR_PNB_SHIFT; + let bank2 = cr_value & regs::SECCR_BKER != 0; if pnb >= regs::PAGES_PER_BANK { - self.secsr |= regs::SR_PGSERR; + self.set_error(via_secure, regs::SR_PGSERR); + return; + } + // Table 68: the controller must match the page label, or the erase is + // ignored and WRPERR is raised. Pages 0..=SECWM_PEND are secure. + let label_secure = pnb <= regs::SECWM_PEND; + if via_secure != label_secure + { + self.set_error(via_secure, regs::SR_WRPERR); return; } if self.wrp_page == Some((bank2, pnb)) { - self.secsr |= regs::SR_WRPERR; + self.set_error(via_secure, regs::SR_WRPERR); return; } - let start = (pnb * regs::PAGE_SIZE) as usize; - let end = start + regs::PAGE_SIZE as usize; - let store = self.store_mut(bank2); - if let Some(slot) = store.get_mut(start..end) + // This erase would take effect, so it is a persistent op the cut walks. + match self.cut_step_simple() { - for byte in slot.iter_mut() + SimpleStep::Proceed | SimpleStep::MutateThenDie => { - *byte = regs::ERASED_BYTE; + let start = (pnb * regs::PAGE_SIZE) as usize; + let end = start + regs::PAGE_SIZE as usize; + let store = self.store_mut(bank2); + if let Some(slot) = store.get_mut(start..end) + { + for byte in slot.iter_mut() + { + *byte = regs::ERASED_BYTE; + } + self.start_busy(via_secure); + } + else + { + self.set_error(via_secure, regs::SR_PGSERR); + } + } + // A dead erase never runs, and never faults (the CPU is off). + SimpleStep::Suppress => + {} + // A power cut before the erase completes fails the op closed. An + // interrupted erase leaves the page contents unchanged in the model, + // which is the fail-closed floor for the metadata page. + SimpleStep::Fault => + { + self.set_error(via_secure, regs::SR_OPERR); } - self.start_busy(); - } - else - { - self.secsr |= regs::SR_PGSERR; } } /// Sets BSY busy for a few polls and raises EOP, modelling op completion. - fn start_busy(&mut self) + /// + /// BSY / WDW are mirrored in both status registers (RM0456 sec 7.3.5), so + /// the busy flags are set in both SECSR and NSSR. EOP is set on the status + /// register of the accessing controller, the one the driver polls. + fn start_busy(&mut self, via_secure: bool) { self.busy_polls = BUSY_POLLS; self.secsr |= regs::SR_BSY; - self.secsr |= regs::SR_EOP; + self.nssr |= regs::SR_BSY; + if via_secure + { + self.secsr |= regs::SR_EOP; + } + else + { + self.nssr |= regs::SR_EOP; + } } - /// Reads SECSR, stepping the BSY busy countdown down on each read. - fn read_secsr(&mut self) -> u32 + /// Steps the shared BSY busy countdown, clearing BSY / WDW in both status + /// registers when it reaches zero. A single op is polled on exactly one + /// status register, so one shared countdown is faithful. + fn tick_busy(&mut self) { - let value = self.secsr; if self.busy_polls > 0 { self.busy_polls -= 1; if self.busy_polls == 0 { self.secsr &= !(regs::SR_BSY | regs::SR_WDW); + self.nssr &= !(regs::SR_BSY | regs::SR_WDW); } } + } + + /// Reads SECSR, stepping the BSY busy countdown down on each read. + fn read_secsr(&mut self) -> u32 + { + let value = self.secsr; + self.tick_busy(); + value + } + + /// Reads NSSR, stepping the BSY busy countdown down on each read. + fn read_nssr(&mut self) -> u32 + { + let value = self.nssr; + self.tick_busy(); value } @@ -450,20 +938,23 @@ impl FlashModel match addr { regs::FLASH_SECKEYR => self.write_seckeyr(value), + regs::FLASH_NSKEYR => self.write_nskeyr(value), regs::FLASH_OPTKEYR => self.write_optkeyr(value), - regs::FLASH_SECSR => self.clear_secsr(value), - regs::FLASH_NSSR => self.nssr &= !value, + regs::FLASH_SECSR => self.clear_status(true, value), + regs::FLASH_NSSR => self.clear_status(false, value), regs::FLASH_SECCR => self.write_seccr(value), regs::FLASH_NSCR => self.write_nscr(value), // An OPTR write lands in the program shadow, not the effective // register (RM0456 sec 7.4.2): the change takes effect only at the // modelled reset, so the running bank stays stable mid-update. regs::FLASH_OPTR => self.optr_shadow = value, - _ => {} + _ => + {} } } - /// Processes the CR unlock key sequence (KEY1 then KEY2). RM0456 sec 7.3.5. + /// Processes the secure CR unlock key sequence (KEY1 then KEY2). RM0456 sec + /// 7.3.5. fn write_seckeyr(&mut self, value: u32) { match (self.cr_key_step, value) @@ -479,6 +970,23 @@ impl FlashModel } } + /// Processes the non-secure CR unlock key sequence (KEY1 then KEY2). RM0456 + /// sec 7.3.5: NSKEYR uses the same key pair as SECKEYR. FLASH_NSCR is RW from + /// both states (RM0456 sec 7.9.9), so secure firmware may unlock it. + fn write_nskeyr(&mut self, value: u32) + { + match (self.ns_cr_key_step, value) + { + (0, v) if v == regs::FLASH_KEY1 => self.ns_cr_key_step = 1, + (1, v) if v == regs::FLASH_KEY2 => + { + self.ns_cr_key_step = 2; + self.nscr &= !regs::NSCR_LOCK; + } + _ => self.ns_cr_key_step = 0, + } + } + /// Processes the option unlock key sequence (OPTKEY1 then OPTKEY2). RM0456 /// sec 7.4.2. Requires the CR already unlocked, as on real silicon. fn write_optkeyr(&mut self, value: u32) @@ -495,15 +1003,25 @@ impl FlashModel } } - /// Clears the rc_w1 SECSR flags the write requests. - fn clear_secsr(&mut self, value: u32) + /// Clears the rc_w1 flags a status-register write requests. + /// + /// BSY / WDW are not rc_w1, so they are preserved and cleared by the model's + /// own busy countdown. `via_secure` picks SECSR or NSSR. + fn clear_status(&mut self, via_secure: bool, value: u32) { - // BSY / WDW are not rc_w1, the model clears them on its own countdown. let rc_w1 = value & !(regs::SR_BSY | regs::SR_WDW); - self.secsr &= !rc_w1; + if via_secure + { + self.secsr &= !rc_w1; + } + else + { + self.nssr &= !rc_w1; + } } - /// Applies a SECCR write, then triggers an erase if STRT just rose. + /// Applies a SECCR write, then triggers a secure-controller erase if STRT + /// just rose with PER set. fn write_seccr(&mut self, value: u32) { let strt_rising = @@ -516,25 +1034,41 @@ impl FlashModel self.seccr = value; if strt_rising && value & regs::SECCR_PER != 0 { - self.erase_selected_page(); + self.erase_page_from_cr(true, value); // STRT auto-clears once the op starts. self.seccr &= !regs::SECCR_STRT; } } - /// Applies an NSCR write, staging a swap on OPTSTRT and recording an - /// OBL_LAUNCH as the inert reset stand-in. + /// Applies an NSCR write. + /// + /// NSCR shares the program / erase bits with SECCR (PG, PER, PNB, BKER, STRT, + /// LOCK) and adds the option-byte bits (OPTLOCK, OPTSTRT, OBL_LAUNCH), RM0456 + /// sec 7.9.9. So this triggers a non-secure-controller erase on STRT rising + /// with PER, stages a swap on OPTSTRT, and records an OBL_LAUNCH as the inert + /// reset stand-in. fn write_nscr(&mut self, value: u32) { + if value & regs::NSCR_LOCK != 0 + { + self.ns_cr_key_step = 0; + } if value & regs::NSCR_OPTLOCK != 0 { self.opt_key_step = 0; } + let strt_rising = + value & regs::SECCR_STRT != 0 && self.nscr & regs::SECCR_STRT == 0; let optstrt_rising = value & regs::NSCR_OPTSTRT != 0 && self.nscr & regs::NSCR_OPTSTRT == 0; let obl_rising = value & regs::NSCR_OBL_LAUNCH != 0 && self.nscr & regs::NSCR_OBL_LAUNCH == 0; self.nscr = value; + if strt_rising && value & regs::SECCR_PER != 0 + { + self.erase_page_from_cr(false, value); + self.nscr &= !regs::SECCR_STRT; + } if optstrt_rising { if self.nscr & regs::NSCR_OPTLOCK != 0 @@ -544,18 +1078,33 @@ impl FlashModel } else { - // Stage the option load. SWAP_BANK in the OPTR shadow is the - // requested state, applied only at the modelled reset. - self.staged_swap = - Some(regs::swap_bank_set(self.optr_shadow)); - self.start_busy(); + // The option stage is a persistent op the cut walks. On a fault + // it raises OPTWERR, so arm_swap fails closed and never reaches + // OBL_LAUNCH. + match self.cut_step_simple() + { + SimpleStep::Proceed | SimpleStep::MutateThenDie => + { + // Stage the option load. SWAP_BANK in the OPTR shadow is + // the requested state, applied only at the modelled reset. + self.staged_swap = + Some(regs::swap_bank_set(self.optr_shadow)); + self.start_busy(false); + } + SimpleStep::Suppress => + {} + SimpleStep::Fault => + { + self.nssr |= regs::SR_OPTWERR; + } + } } self.nscr &= !regs::NSCR_OPTSTRT; } if obl_rising { // OBL_LAUNCH resets the part and applies the option load on real - // silicon. The model records it WITHOUT resetting, so no test ever + // silicon. The model records it without resetting, so no test ever // performs a real option load. The staged swap is applied only by an // explicit apply_reset, never here. self.obl_launched = true; @@ -563,13 +1112,22 @@ impl FlashModel } } - /// Reads a 32-bit word from the resolved physical store, or 0 out of range. + /// Reads a 32-bit word from the resolved physical store, modelling RAZ. + /// + /// RM0456 Table 68: a read whose alias view does not match the page label + /// returns zero (Read-As-Zero), so a non-secure image page read through the + /// secure alias is all zeros. A matching read returns the stored word. fn read_flash_word(&self, addr: u32) -> u32 { match self.resolve(addr) { Some(resolved) => { + if resolved.alias_secure != page_label_secure(resolved.index) + { + // Wrong alias for this page's label: RAZ. + return 0; + } let store = self.store(resolved.bank2); match store.get(resolved.index..resolved.index + 4) { @@ -590,7 +1148,19 @@ impl FlashModel /// Builds a heap-allocated, fully-erased 256 KB bank store. fn erased_bank() -> Box<[u8; BANK_BYTES]> { - let boxed: Box<[u8]> = vec![regs::ERASED_BYTE; BANK_BYTES].into_boxed_slice(); + boxed_bank(regs::ERASED_BYTE) +} + +/// Builds a heap-allocated, all-zero 256 KB buffer (the RAZ backing). +fn zero_bank() -> Box<[u8; BANK_BYTES]> +{ + boxed_bank(0) +} + +/// Builds a heap-allocated 256 KB store filled with `fill`. +fn boxed_bank(fill: u8) -> Box<[u8; BANK_BYTES]> +{ + let boxed: Box<[u8]> = vec![fill; BANK_BYTES].into_boxed_slice(); boxed .try_into() .expect("bank store boxes to a fixed-size array") @@ -603,7 +1173,7 @@ impl FlashAccess for FlashModel match addr { regs::FLASH_SECSR => self.read_secsr(), - regs::FLASH_NSSR => self.nssr, + regs::FLASH_NSSR => self.read_nssr(), regs::FLASH_SECCR => self.seccr, regs::FLASH_NSCR => self.nscr, regs::FLASH_OPTR => self.optr, @@ -614,12 +1184,22 @@ impl FlashAccess for FlashModel fn write32(&mut self, addr: u32, value: u32) { - if self.is_flash(addr) + if let Some(resolved) = self.resolve(addr) { - // A word write to a flash address programs only while PG is set, - // otherwise it is ignored (a real flash address is read-only without - // an armed program, RM0456 sec 7.3.7). - if self.seccr & regs::SECCR_PG != 0 + // A word write to a flash address programs only while the accessing + // controller's PG is set (secure alias -> SECCR.PG, non-secure alias + // -> NSCR.PG), otherwise it is ignored (a real flash address is + // read-only without an armed program, RM0456 sec 7.3.7). PG shares + // bit 0 across the two control registers. + let armed = if resolved.alias_secure + { + self.seccr & regs::SECCR_PG + } + else + { + self.nscr & regs::SECCR_PG + }; + if armed != 0 { self.program_word(addr, value); } @@ -645,10 +1225,24 @@ impl FlashAccess for FlashModel { match self.resolve(base) { - Some(resolved) => self - .store(resolved.bank2) - .get(resolved.index..resolved.index + len) - .unwrap_or(&[]), + Some(resolved) => + { + let end = core::cmp::min(resolved.index + len, BANK_BYTES); + let start = core::cmp::min(resolved.index, end); + // Table 68: a band read through the wrong alias for its label + // returns RAZ (all zeros), not the stored bytes. A band is + // homogeneous, so the base page's label decides the whole read. + if resolved.alias_secure == page_label_secure(resolved.index) + { + self.store(resolved.bank2) + .get(start..end) + .unwrap_or(&[]) + } + else + { + self.zeros.get(start..end).unwrap_or(&[]) + } + } None => &[], } } diff --git a/crates/mcu-flash/src/mpu_containment_tests.rs b/crates/mcu-flash/src/mpu_containment_tests.rs new file mode 100644 index 0000000..590f79d --- /dev/null +++ b/crates/mcu-flash/src/mpu_containment_tests.rs @@ -0,0 +1,234 @@ +//! Proves every store / load address the driver emits for the inactive bank and +//! the metadata band lands inside a secure MPU region. +//! +//! The secure MPU runs with `PRIVDEFENA` = 0 (no background map, RM0456 sec 3.5), +//! so any address the secure core touches that no region covers HardFaults on +//! silicon, invisibly to a host test with no MPU model (the se_readonly fault +//! class). A geometry edit (a new descriptor page, a moved payload origin) can +//! silently push a driver-emitted address outside its region. +//! +//! This test drives the real driver over the faithful FLASH-controller model +//! through a recording port that captures every flash-bank address the driver +//! actually emits, then asserts each captured range is contained in a mirrored +//! secure MPU region. It fails if a future edit moves an emitted address outside +//! its region. +//! +//! The MPU region bounds are mirrored here as hard literals. Their source of +//! truth is `crates/platform/src/map.rs`, whose own pin tests fix the same +//! literals, so the two sides pin the same numbers and this test catches a +//! geometry edit on the mcu-flash side. + +#![cfg(test)] + +extern crate alloc; + +use alloc::vec::Vec; +use core::cell::RefCell; + +use fw_update::BankId; +use fw_update::FlashSeam; +use fw_update::PendingFlag; +use fw_update::UpdateOutcome; + +use crate::bus::FlashAccess; +use crate::driver::Stm32FlashSeam; +use crate::model::FlashModel; +use crate::regs; + +// The secure MPU regions a driver-emitted flash address can legitimately fall in, +// as inclusive [base, limit] pairs. Mirrored from crates/platform/src/map.rs: +// R1 boot metadata (physical Bank 1 pages 0-1), low alias when SWAP_BANK clear, +// high alias when set. +// R5 inactive-bank secure image (pages 9-19, high alias): descriptor plus +// secure payload. +// R6 inactive-bank non-secure image (pages 20-31, high NS alias). +const R1_META_LOW: (u32, u32) = (0x0C00_0000, 0x0C00_3FFF); +const R1_META_HIGH: (u32, u32) = (0x0C04_0000, 0x0C04_3FFF); +const R5_INACTIVE_SECURE: (u32, u32) = (0x0C05_2000, 0x0C06_7FFF); +const R6_INACTIVE_NS: (u32, u32) = (0x0806_8000, 0x0807_FFFF); + +const MPU_REGIONS: [(u32, u32); 4] = + [R1_META_LOW, R1_META_HIGH, R5_INACTIVE_SECURE, R6_INACTIVE_NS]; + +/// True when `addr` is inside either flash bank alias (secure 0x0C.. or NS 0x08..). +/// The FLASH controller registers sit at 0x5002_xxxx and are excluded. +fn is_bank_addr(addr: u32) -> bool +{ + (0x0800_0000..0x0808_0000).contains(&addr) + || (0x0C00_0000..0x0C08_0000).contains(&addr) +} + +/// True when the inclusive range `[base, base + len)` sits wholly in some region. +fn contained(base: u32, len: u32) -> bool +{ + if len == 0 + { + return true; + } + let last = base + len - 1; + MPU_REGIONS + .iter() + .any(|(lo, hi)| base >= *lo && last <= *hi) +} + +/// A recording [`FlashAccess`] port over the FLASH-controller model. +/// +/// It delegates every access to the model and records the address of every +/// flash-bank read / write / borrow, so a test can prove what the driver emits. +struct RecordingAccess +{ + inner: FlashModel, + ranges: RefCell>, +} + +impl RecordingAccess +{ + fn new() -> RecordingAccess + { + RecordingAccess + { + inner: FlashModel::new(), + ranges: RefCell::new(Vec::new()), + } + } + + fn record(&self, addr: u32, len: u32) + { + if is_bank_addr(addr) + { + self.ranges.borrow_mut().push((addr, len)); + } + } + + fn ranges(&self) -> Vec<(u32, u32)> + { + self.ranges.borrow().clone() + } + + fn apply_reset(&mut self) + { + self.inner.apply_reset(); + } +} + +impl FlashAccess for RecordingAccess +{ + fn read32(&mut self, addr: u32) -> u32 + { + self.record(addr, 4); + self.inner.read32(addr) + } + + fn write32(&mut self, addr: u32, value: u32) + { + self.record(addr, 4); + self.inner.write32(addr, value); + } + + fn peek32(&self, addr: u32) -> u32 + { + self.record(addr, 4); + self.inner.peek32(addr) + } + + fn bank_view(&self, base: u32, len: usize) -> &[u8] + { + self.record(base, len as u32); + self.inner.bank_view(base, len) + } +} + +/// Drives a full inactive-bank flow (erase, payload writes spanning both +/// sub-bands, descriptor write, the three band reads, and every metadata op) and +/// returns the recording port holding every flash address the driver emitted. +/// +/// When `swap_set` is true a commit plus a modelled reset first flips SWAP_BANK, +/// so physical Bank 1 (the metadata) and the inactive bank both sit at the high +/// alias, the case that breaks a low-alias-only assumption. +fn drive_full_inactive_flow(swap_set: bool) -> Stm32FlashSeam +{ + let mut driver = Stm32FlashSeam::new(RecordingAccess::new()); + if swap_set + { + driver.commit_swap().expect("stage swap"); + driver.access_mut().apply_reset(); + } + driver.erase_inactive().expect("erase inactive"); + // A secure payload page (index 0) and the first non-secure payload page. + driver + .write_inactive_page(0, &[0xAA; 16]) + .expect("write secure payload page"); + let ns_page = + (regs::IMAGE_PAYLOAD_SECURE_SIZE / fw_update::PAGE_LEN as u32) as u16; + driver + .write_inactive_page(ns_page, &[0xBB; 16]) + .expect("write non-secure payload page"); + driver + .write_descriptor(&[0xCC; 88]) + .expect("write descriptor"); + let _ = driver.inactive_descriptor(); + let _ = driver.inactive_secure_band(); + let _ = driver.inactive_ns_band(); + driver.nvcnt_bump(5).expect("nvcnt bump"); + driver + .pending_write(PendingFlag::Armed(BankId::Bank2)) + .expect("pending write"); + driver.boot_count_advance().expect("boot count advance"); + driver + .update_outcome_write(UpdateOutcome::AutoReverted) + .expect("outcome write"); + driver +} + +/// Asserts every recorded flash range is contained in a secure MPU region, and +/// that the flow actually emitted flash addresses (non-vacuous). +fn assert_all_contained(ranges: &[(u32, u32)], context: &str) +{ + assert!( + !ranges.is_empty(), + "the {context} flow emitted no flash addresses, the test is vacuous" + ); + for (base, len) in ranges + { + assert!( + contained(*base, *len), + "{context}: driver emitted [{base:#010x}, len {len}] outside every \ + secure MPU region" + ); + } +} + +#[test] +fn every_driver_emitted_address_is_inside_an_mpu_region() +{ + // SWAP_BANK clear: the inactive bank is at the high alias (R5 / R6) and the + // metadata is at the low alias (R1 low). + let driver = drive_full_inactive_flow(false); + assert_all_contained(&driver.access().ranges(), "swap-clear"); + + // SWAP_BANK set: physical Bank 1 (the metadata) moves to the high alias (R1 + // high), and the inactive bank stays at the high alias (R5 / R6). + let driver = drive_full_inactive_flow(true); + assert_all_contained(&driver.access().ranges(), "swap-set"); +} + +#[test] +fn the_containment_check_is_not_vacuous() +{ + // A range one byte past R5's limit must be rejected, so a real geometry drift + // (an emitted address sliding out of its region) is caught, not silently + // accepted. This pins the check itself, independent of the driver run. + assert!( + contained(R5_INACTIVE_SECURE.0, 16), + "a range at R5's base must be contained" + ); + let past = R5_INACTIVE_SECURE.1 - 15; + assert!( + contained(past, 16), + "a range ending at R5's limit must be contained" + ); + assert!( + !contained(R5_INACTIVE_SECURE.1 - 14, 16), + "a range ending one byte past R5's limit must be rejected" + ); +} diff --git a/crates/mcu-flash/src/power_fault_tests.rs b/crates/mcu-flash/src/power_fault_tests.rs new file mode 100644 index 0000000..f13e372 --- /dev/null +++ b/crates/mcu-flash/src/power_fault_tests.rs @@ -0,0 +1,1034 @@ +//! The machine-checked power-fault campaign, over the REAL flash driver. +//! +//! This is the register-level successor to the retired seam-level fw-update +//! harness. It drives the `fw-update` [`fw_update::Updater`] through its public +//! API over [`Stm32FlashSeam`] backed by the faithful [`FlashModel`], and injects +//! a power cut at every persistent flash operation the driver issues, across the +//! modelled reset boundary. So each injected cut hits the real driver code (the +//! unlock / program / poll / lock sequencing and the physical-bank addressing) +//! over the register-level model, which the seam-level harness never exercised. +//! +//! # A single global cut index that survives the reset +//! +//! [`FlashModel`] carries the armed cut countdown in its non-volatile state, and a +//! modelled reset ([`FlashModel::apply_reset`]) clears only the volatile +//! controller state, so the countdown rides across the reset. A cut can therefore +//! fire at the flash ops around the post-reset confirm (the pending clear and the +//! NVCNT bump done last) or revert (the reverse-swap arm and the pending clear) +//! path, exactly the most safety-critical orderings. The SE spend is a +//! `SeCounterSeam` op, not a flash op, so it lies outside this flash-cut index +//! domain, and its interruption is proven by the dedicated channel-drop test. +//! Every persistent op is a quad-word program, a page erase, or an option-byte +//! stage, so the cut index walks each one. +//! +//! # The per-index census +//! +//! The campaign measures the confirm and revert flow lengths, then arms a cut at +//! every index of both, over both option-load-at-reset outcomes and all three cut +//! modes. It records which index fired and asserts every reachable index fired at +//! least once, which is the check that catches a cut span with a gap. +//! +//! # Two physically separate banks, real bytes +//! +//! [`FlashModel`] holds two physical bank stores. The old bank (physical Bank 1) +//! is seeded with a valid signed image in the exact de-interleaved layout the +//! driver writes, and the invariant reads the model's real old-bank bytes back +//! (never a rebuilt copy), so the old-bank-bootable assertion cannot pass +//! vacuously. The revert direction is modelled at the SWAP_BANK bit level, and the +//! harness asserts which physical bank boots after the modelled reset by reading +//! the real option state. + +#![cfg(test)] + +extern crate alloc; + +use alloc::rc::Rc; +use alloc::vec; +use alloc::vec::Vec; +use core::cell::RefCell; + +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; + +use fw_update::FlashSeam; +use fw_update::PendingFlag; +use fw_update::SeCounterError; +use fw_update::SeCounterSeam; +use fw_update::UpdateState; +use fw_update::Updater; + +use image_verify::HEADER_LEN; +use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; +use image_verify::RootKey; +use image_verify::SIG_LEN; +use image_verify::encode_header; +use image_verify::verify_image; + +use crate::bus::FlashAccess; +use crate::driver::Stm32FlashSeam; +use crate::model::CutMode; +use crate::model::FlashModel; +use crate::regs; + +// The image security counters. The OLD bank and the stored NVCNT start below the +// new image counter, so the update is a forward step, not a downgrade. +const OLD_BANK_COUNTER: u32 = 4; +const NEW_IMAGE_COUNTER: u32 = 5; +const BASELINE_NVCNT: u32 = 4; + +// An SE counter value whose derived anti-rollback floor equals the new image +// counter, so Gate 2 accepts the forward step (floor = ORIGIN - value). +const SE_AT_FLOOR: u32 = fw_update::SE_COUNTER_ORIGIN - NEW_IMAGE_COUNTER; + +// A small payload for the census, so the persistent-op count stays bounded and +// each flow is fast. A larger payload for the torn-page test, so a full page +// flushes during receive where the tear lands. +const OLD_PAYLOAD: &[u8] = b"old firmware payload v1.."; +const NEW_PAYLOAD: &[u8] = b"new firmware payload bytes for the a/b census"; + +// The number of erase ops erase_inactive issues (image pages 9..31 of the +// inactive bank), so the torn-page test can arm at the first payload program. +const ERASE_OPS: u32 = regs::PAGES_PER_BANK - regs::IMAGE_PAGE_FIRST; + +// The dev private scalar, test only. A publicly known, hardcoded key that makes +// every fixture deterministic. The all-0x01 value is a valid P-256 scalar: +// non-zero, and far below the curve order, which starts with 0xFF. +const DEV_SCALAR: [u8; 32] = [1u8; 32]; + +/// Which recovery branch the post-reset boot drives once the swap took effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Health +{ + /// The new bank is healthy, so on_boot reaches AwaitingConfirm and confirm is + /// driven. The flash cut can hit the confirm flash ops (the pending clear and + /// the NVCNT bump done last). The SE spend is a separate `SeCounterSeam` op, + /// outside the flash-cut domain, proven by the dedicated channel-drop test. + Confirm, + /// The new bank failed its health check, so revert is driven instead (the + /// reverse-swap arm, the pending clear). + Revert, +} + +/// A shared handle to the FLASH-controller model. +/// +/// [`Updater::new`] takes the seam by value, so the harness keeps a clone of this +/// `Rc` to arm the cut, inspect the backing flash, and model a reboot by building +/// a fresh driver over the same model. `RefCell` gives the `&mut self` borrow each +/// [`FlashAccess`] call needs from behind the shared handle. +#[derive(Clone)] +struct Shared +{ + model: Rc>, +} + +impl Shared +{ + fn new() -> Shared + { + Shared + { + model: Rc::new(RefCell::new(FlashModel::new())), + } + } +} + +impl FlashAccess for Shared +{ + fn read32(&mut self, addr: u32) -> u32 + { + self.model.borrow_mut().read32(addr) + } + + fn write32(&mut self, addr: u32, value: u32) + { + self.model.borrow_mut().write32(addr, value); + } + + fn peek32(&self, addr: u32) -> u32 + { + self.model.borrow_mut().read32(addr) + } + + fn bank_view(&self, base: u32, len: usize) -> &[u8] + { + // Resolve the band read through the model (RM0456 sec 7.5.8 swap mapping + // plus RM0456 Table 68 RAZ on a wrong-alias read), then return the + // equivalent borrow. This mirrors the machine-integration double. + let model = self.model.borrow(); + let (ptr, start, end) = match model.band_ptr(base, len) + { + Some(triple) => triple, + None => return &[], + }; + // Drop the Ref now that the stable store address is captured. The bytes + // outlive `&self` because the Rc keeps the model alive and each store (and + // the RAZ buffer) is a boxed array whose address is stable for the model's + // life. + drop(model); + // SAFETY: this is a TEST double, the host analogue of memory-mapped flash, + // not the production MMIO port. `ptr` is one of the model's boxed bank + // arrays or its RAZ buffer, kept alive by the Rc the harness still holds, + // with a stable address for the model's life. The range `start..end` is + // clamped inside that array span by `band_ptr`, the bytes are plain `u8`. + // The returned slice is fully consumed before any other handle is touched, + // and the borrowed bytes are immutable flash during the verifying read. + #[allow(unsafe_code)] + unsafe + { + core::slice::from_raw_parts(ptr.add(start), end - start) + } + } +} + +/// A local secure-element counter double for the census (no drop switch). +/// +/// Models the TROPIC01 MCounter abstractly: it counts DOWN from a provisioned +/// origin and [`SeCounterSeam::update`] decrements it. The census rebuilds it per +/// boot, matching the retired harness, so the flash invariants are what the +/// census proves. SE persistence is proven by the dedicated SE-spend test. +struct LocalSeCounter +{ + value: u32, +} + +impl LocalSeCounter +{ + fn new(value: u32) -> LocalSeCounter + { + LocalSeCounter { value } + } +} + +impl SeCounterSeam for LocalSeCounter +{ + fn read(&mut self) -> Result + { + Ok(self.value) + } + + fn update(&mut self) -> Result<(), SeCounterError> + { + self.value = self + .value + .checked_sub(1) + .ok_or(SeCounterError::Exhausted)?; + Ok(()) + } +} + +/// A persistent secure-element counter with a one-shot channel-drop switch. +/// +/// The value persists across modelled boots (the `Rc`), so the SE-spend test can +/// prove a dropped spend on one boot does not double-spend on the next. +#[derive(Clone)] +struct SharedSe +{ + inner: Rc>, +} + +struct SeState +{ + value: u32, + updated: bool, + drop_next: bool, +} + +impl SharedSe +{ + fn new(value: u32) -> SharedSe + { + SharedSe + { + inner: Rc::new(RefCell::new(SeState + { + value, + updated: false, + drop_next: false, + })), + } + } + + fn arm_drop(&self) + { + self.inner.borrow_mut().drop_next = true; + } + + fn value(&self) -> u32 + { + self.inner.borrow().value + } + + fn updated(&self) -> bool + { + self.inner.borrow().updated + } +} + +impl SeCounterSeam for SharedSe +{ + fn read(&mut self) -> Result + { + Ok(self.inner.borrow().value) + } + + fn update(&mut self) -> Result<(), SeCounterError> + { + let mut state = self.inner.borrow_mut(); + if state.drop_next + { + // The channel dropped during the spend. The counter does not + // decrement, so the next boot reads the same value and retries. + state.drop_next = false; + return Err(SeCounterError::Unavailable); + } + state.value = state + .value + .checked_sub(1) + .ok_or(SeCounterError::Exhausted)?; + state.updated = true; + Ok(()) + } +} + +// The signing key of the dev scalar. +fn dev_signing_key() -> SigningKey +{ + SigningKey::from_slice(&DEV_SCALAR).expect("the dev scalar is in [1, n-1]") +} + +// Builds a HEADER || payload || signature image signed with the dev scalar, using +// the image-verify encode feature so the layout has a single source of truth. The +// signature is normalized to low-s, the only encoding the verifier accepts. +fn dev_image(security_counter: u32, payload: &[u8]) -> Vec +{ + let version = ImageVersion + { + major: 1, + minor: 0, + revision: 0, + build: 0, + }; + let header = encode_header(version, security_counter, payload.len() as u32); + let mut signed = Vec::new(); + signed.extend_from_slice(&header); + signed.extend_from_slice(payload); + let sig: p256::ecdsa::Signature = dev_signing_key().sign(&signed); + let sig = sig.normalize_s(); + let mut image = signed; + image.extend_from_slice(&sig.to_bytes()); + image +} + +// The dev root key, derived from the dev scalar, so this crate carries no second +// copy of a key constant to drift. +fn dev_root() -> RootKey +{ + let point = dev_signing_key().verifying_key().to_sec1_point(false); + let mut bytes = [0u8; ROOT_KEY_LEN]; + bytes.copy_from_slice(point.as_ref()); + RootKey::from_bytes(bytes).expect("the derived dev root key is on-curve") +} + +// Seeds a physical bank store with a signed image in the exact de-interleaved +// layout the driver writes: the header into the descriptor page [0:24], the +// signature into the descriptor [24:88], and the payload into the payload band +// from offset 0. The seeding is bank-relative and alias-independent, so it holds +// across a swap. Assumes the payload fits the secure sub-band (true for the small +// census images), so the whole payload lands contiguously at the payload offset. +fn seed_bank_image(model: &mut FlashModel, bank2: bool, image: &[u8]) +{ + let payload_len = image.len() - HEADER_LEN - SIG_LEN; + let descriptor = regs::IMAGE_DESCRIPTOR_OFFSET as usize; + let payload = regs::IMAGE_PAYLOAD_OFFSET as usize; + for (i, byte) in image[..HEADER_LEN].iter().enumerate() + { + model.poke_phys(bank2, descriptor + i, *byte); + } + let sig = &image[HEADER_LEN + payload_len..]; + for (i, byte) in sig.iter().enumerate() + { + model.poke_phys(bank2, descriptor + HEADER_LEN + i, *byte); + } + for (i, byte) in image[HEADER_LEN..HEADER_LEN + payload_len].iter().enumerate() + { + model.poke_phys(bank2, payload + i, *byte); + } +} + +// Reassembles the de-interleaved image out of a physical bank store and verifies +// it against the root key. Reads the model's real bytes (never a rebuilt copy), +// so a passing verify proves the store actually holds a bootable image. +fn bank_verifies +( + model: &FlashModel, + bank2: bool, + payload_len: usize, + root: &RootKey, +) + -> bool +{ + let descriptor = regs::IMAGE_DESCRIPTOR_OFFSET as usize; + let payload = regs::IMAGE_PAYLOAD_OFFSET as usize; + let mut header = Vec::with_capacity(HEADER_LEN); + let mut sig = Vec::with_capacity(SIG_LEN); + let mut body = Vec::with_capacity(payload_len); + for i in 0..HEADER_LEN + { + match model.phys_byte(bank2, descriptor + i) + { + Some(byte) => header.push(byte), + None => return false, + } + } + for i in 0..SIG_LEN + { + match model.phys_byte(bank2, descriptor + HEADER_LEN + i) + { + Some(byte) => sig.push(byte), + None => return false, + } + } + for i in 0..payload_len + { + match model.phys_byte(bank2, payload + i) + { + Some(byte) => body.push(byte), + None => return false, + } + } + let segments: [&[u8]; 3] = [&header, &body, &sig]; + verify_image(&segments, root).is_ok() +} + +// Builds a fresh model seeded with the old bank image (physical Bank 1) plus the +// baseline NVCNT, both poked physically so the seeding never ticks the cut +// counter. The inactive bank (physical Bank 2) stays erased. +fn seeded_shared(old_image: &[u8]) -> Shared +{ + let shared = Shared::new(); + { + let mut model = shared.model.borrow_mut(); + seed_bank_image(&mut model, false, old_image); + // Seed the NVCNT log slot 0 directly, so the seeding does not count as a + // persistent op (the census length must reflect only the flow). + let base = regs::META_NVCNT_OFFSET as usize; + for (i, byte) in BASELINE_NVCNT.to_le_bytes().iter().enumerate() + { + model.poke_phys(false, base + i, *byte); + } + } + shared +} + +// Reads the pending record through a fresh probe driver (a read, no mutation). +fn pending_of(shared: &Shared) -> PendingFlag +{ + let mut probe = Stm32FlashSeam::new(shared.clone()); + probe.pending_read().expect("pending read") +} + +// Reads the NVCNT through a fresh probe driver. +fn nvcnt_of(shared: &Shared) -> u32 +{ + let mut probe = Stm32FlashSeam::new(shared.clone()); + probe.nvcnt_read().expect("nvcnt read") +} + +// Streams the image through the public receive API in small chunks, in order. +// Stops and returns the error on the first faulted chunk. +fn stream_chunks(up: &mut CensusUpdater<'_>, image: &[u8]) -> bool +{ + let mut offset = 0usize; + for chunk in image.chunks(13) + { + if up.receive_chunk(offset, chunk).is_err() + { + return false; + } + offset += chunk.len(); + } + true +} + +// Convenience alias for the concrete Updater the census drives: the real driver +// over the shared FLASH-controller model, so the injected cuts hit the real +// driver code. +type CensusUpdater<'k> = Updater<'k, Stm32FlashSeam, LocalSeCounter>; + +// The settled result of driving one flow. +struct FlowOutcome +{ + shared: Shared, + fired: bool, + ops: u32, +} + +// Drives the whole flow under an optional single global cut. +// +// Segment 1 runs begin -> receive -> accept -> commit with the cut armed. The cut +// countdown that survives rides in the model. `reset_applied` models the +// option-load-at-reset window: true applies the staged swap atomically (RM0456 +// sec 7.5.8), false models a cut before the option load committed, dropping the +// stage so the old bank boots. After the reset the harness reboots repeatedly, +// running on_boot recovery and driving confirm or revert by `health`, until the +// state settles. +fn run_flow +( + root: &RootKey, + old_image: &[u8], + new_image: &[u8], + cut: Option<(u32, CutMode)>, + reset_applied: bool, + health: Health, +) + -> FlowOutcome +{ + let shared = seeded_shared(old_image); + if let Some((index, mode)) = cut + { + shared.model.borrow_mut().arm_cut(index, mode); + } + + { + let se = LocalSeCounter::new(SE_AT_FLOOR); + let driver = Stm32FlashSeam::new(shared.clone()); + let mut up: CensusUpdater = Updater::new(root, driver, se); + + // Stream and accept. Any error here is the cut firing during erase, a page + // write, a descriptor write, or a read, which collapses the machine + // fail-closed. + let accepted = up.begin(new_image.len()).is_ok() + && stream_chunks(&mut up, new_image) + && up.verify_and_accept().is_ok(); + // Commit the swap if accepted. A cut here leaves the staged swap either + // set or not, which the modelled reset resolves. + let _committed = accepted && up.commit().is_ok(); + // Drop the volatile updater and its driver: this is the reboot. The model + // persists in the Rc. + } + + // Model the option-load-at-reset window (RM0456 sec 7.5.8). + if reset_applied + { + shared.model.borrow_mut().apply_reset(); + } + else + { + shared.model.borrow_mut().reboot_without_option_load(); + } + + drive_recovery(root, &shared, health); + + let fired = shared.model.borrow().cut_fired(); + let ops = shared.model.borrow().persistent_ops(); + FlowOutcome + { + shared, + fired, + ops, + } +} + +// Reboots repeatedly from the surviving model until the state settles, driving +// on_boot recovery and then confirm or revert by `health` on each boot. A cut that +// survived into the post-reset segment fires inside one of these boots and faults +// the recovery mid-way. The next boot runs on fresh silicon (the reset cleared the +// dead flag, the cut is spent) and retries, so the loop runs until the state +// reaches a fixed point with no staged swap and no pending record. +fn drive_recovery(root: &RootKey, shared: &Shared, health: Health) +{ + let mut guard = 0u32; + loop + { + guard += 1; + assert!(guard < 32, "recovery must settle in a bounded number of boots"); + + // Each boot after the first applies the staged option load atomically: a + // reboot is a reset. The caller resolved the first reset. + if guard > 1 + { + shared.model.borrow_mut().apply_reset(); + } + + let ops_before = shared.model.borrow().persistent_ops(); + { + let se = LocalSeCounter::new(SE_AT_FLOOR); + let driver = Stm32FlashSeam::new(shared.clone()); + let mut up: CensusUpdater = Updater::new(root, driver, se); + if let Ok(UpdateState::AwaitingConfirm) = up.on_boot() + { + match health + { + Health::Confirm => + { + let _ = up.confirm(NEW_IMAGE_COUNTER); + } + Health::Revert => + { + let _ = up.revert(); + } + } + } + } + + let quiescent = shared.model.borrow().staged_swap().is_none() + && pending_of(shared) == PendingFlag::None; + let ops_after = shared.model.borrow().persistent_ops(); + // Settled once no swap is staged, no record dangles, and a full clean boot + // cycle issued no persistent op (a functional fixed point). + if quiescent && ops_after == ops_before + { + break; + } + } +} + +/// The end-to-end disposition of a settled flow, decided from its intended health +/// branch and the real physical boot bank. +/// +/// A [`Health::Revert`] flow must always settle back on the old bank. A +/// [`Health::Confirm`] flow settles [`Settled::Confirmed`] on the new bank once the +/// swap is confirmed end to end, and on the old bank when a cut kept it from ever +/// confirming. Deciding the required bank from the disposition, not from the raw +/// boot bank alone, is what restores the retired seam-level harness's per-health +/// outcome parity, so a revert that erroneously ends confirmed on the new bank +/// fails the census instead of passing as "whatever boots verifies". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Settled +{ + /// The old bank (physical Bank 1) must boot: the swap was never confirmed. + OldBank, + /// The new bank (physical Bank 2) must boot: the swap was confirmed end to end. + Confirmed, +} + +// Decides the settled disposition from the health branch and the real boot bank. A +// confirmed swap requires the Confirm branch and the new bank actually booting. +// Every revert flow, and any confirm flow a cut kept from confirming, is OldBank. +// The pending record is already proven None by the caller, so the disposition +// turns on the health branch and the boot bank alone. +fn settled_disposition(health: Health, boots_new: bool) -> Settled +{ + if health == Health::Confirm && boots_new + { + Settled::Confirmed + } + else + { + Settled::OldBank + } +} + +// Asserts the safety invariant on a settled model, given the flow's health branch +// and the OLD and NEW image payload lengths. +// +// (a) the old bank stays bootable until a swap is confirmed, and a revert flow +// always returns to the old bank, +// (b) the booting bank always verifies and an unverified image never boots, +// (c) the NVCNT never rises above the security counter of the bank that boots, +// (d) no settled state leaves a staged swap or a dangling pending record. +// +// The expected physical bank is required per disposition (see settled_disposition), +// not merely read off the model, so a Revert flow that ends confirmed on the new +// bank fails here rather than passing as "whatever bank boots verifies". +fn assert_invariants +( + shared: &Shared, + old_pl: usize, + new_pl: usize, + root: &RootKey, + health: Health, +) +{ + // (d) No swap may be left staged and no pending record may dangle. + assert_eq!( + shared.model.borrow().staged_swap(), + None, + "no staged swap may survive a settled recovery" + ); + assert_eq!( + pending_of(shared), + PendingFlag::None, + "no pending record may survive a settled recovery" + ); + + // Capture the boot bank and the NVCNT through scoped borrows first, so no + // shared borrow of the model is held while a probe driver takes a mutable one. + let boots_new = shared.model.borrow().boots_bank2(); + let nvcnt = nvcnt_of(shared); + let settled = settled_disposition(health, boots_new); + + match settled + { + Settled::Confirmed => + { + // The confirm flow reached an end-to-end confirmed swap, so the new + // bank (physical Bank 2) must boot. + assert!(boots_new, "a confirmed swap must boot the NEW bank"); + // (b) The booting bank must verify, read from the real store. + let verified = bank_verifies(&shared.model.borrow(), true, new_pl, root); + assert!(verified, "the confirmed NEW bank bytes must verify"); + // (c) NVCNT never above the booting bank counter. + assert!( + nvcnt <= NEW_IMAGE_COUNTER, + "NVCNT must not exceed the booting bank counter" + ); + } + Settled::OldBank => + { + // A revert flow, or a confirm flow a cut kept from confirming, so the + // old bank (physical Bank 1) must boot. A wrong-direction revert that + // left the new bank booting fails this assertion, restoring the retired + // harness's per-health outcome check across every cut index. + assert!( + !boots_new, + "an unconfirmed or reverted flow must boot the OLD bank" + ); + // (b) The OLD bank bytes in the MODEL must still verify (non-vacuous: + // these are the actual seeded bytes, never a rebuilt copy). + let verified = bank_verifies(&shared.model.borrow(), false, old_pl, root); + assert!(verified, "the OLD bank bytes must still verify"); + // (c) No Gate-1 poisoning: an unconfirmed update must not raise NVCNT + // above the OLD bank counter. + assert!( + nvcnt <= OLD_BANK_COUNTER, + "NVCNT must not rise above the OLD bank on an unconfirmed update" + ); + } + } +} + +const CUT_MODES: [CutMode; 3] = +[ + CutMode::BeforeMutation, + CutMode::AfterMutation, + CutMode::TornWrite, +]; +const RESET_OUTCOMES: [bool; 2] = [true, false]; +const HEALTHS: [Health; 2] = [Health::Confirm, Health::Revert]; + +#[test] +fn exhaustive_power_fault_interleavings_hold_the_invariant() +{ + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + let new_image = dev_image(NEW_IMAGE_COUNTER, NEW_PAYLOAD); + let old_pl = OLD_PAYLOAD.len(); + let new_pl = NEW_PAYLOAD.len(); + + // Measure each health branch's flow length with a clean run (no cut). The + // longer branch defines the census index range, and its ops are contiguous + // indices 0..len, so arming each index there fires. + let confirm_len = + run_flow(&root, &old_image, &new_image, None, true, Health::Confirm).ops; + let revert_len = + run_flow(&root, &old_image, &new_image, None, true, Health::Revert).ops; + let n = confirm_len.max(revert_len); + assert!(n > ERASE_OPS, "the flow must reach past the inactive-bank erase"); + + let mut fired_seen = vec![false; n as usize]; + let mut total = 0u32; + let mut fired_count = 0u32; + + for mode in CUT_MODES + { + for reset_applied in RESET_OUTCOMES + { + for health in HEALTHS + { + for k in 0..n + { + let out = run_flow( + &root, + &old_image, + &new_image, + Some((k, mode)), + reset_applied, + health, + ); + assert_invariants(&out.shared, old_pl, new_pl, &root, health); + total += 1; + if out.fired + { + fired_count += 1; + if let Some(slot) = fired_seen.get_mut(k as usize) + { + *slot = true; + } + } + } + } + } + } + + // Every persistent-op index must have fired at least once across the census. + // This is the assertion that proves the cut spans the whole flow, including + // the post-reset confirm and revert mutations. + for (idx, seen) in fired_seen.iter().enumerate() + { + assert!( + *seen, + "persistent op index {idx} never fired, the cut span has a gap" + ); + } + + std::eprintln!( + "register-level power-fault harness: {total} interleavings exercised, \ + {fired_count} cuts fired, every one of {n} persistent-op indices fired \ + at least once over the real driver" + ); + assert!(fired_count > 0, "at least one cut must have fired"); +} + +#[test] +fn rejected_image_never_commits_at_any_cut() +{ + // Stream an image with a bad signature, drive the full flow with a cut at + // every index in every mode, and assert it never reaches a confirmed swap and + // the old bank always boots. + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + let mut bad_image = dev_image(NEW_IMAGE_COUNTER, NEW_PAYLOAD); + // Flip a byte inside the trailing signature so the ECDSA check fails at the + // signature, not on a structural error. The low half of the s scalar keeps + // the signature low-s and well-formed. + let last = bad_image.len() - 1; + bad_image[last] ^= 0x01; + let self_check: [&[u8]; 1] = [&bad_image]; + assert_eq!( + verify_image(&self_check, &root), + Err(image_verify::VerifyError::BadSignature), + "the rejected fixture must fail at the signature check" + ); + + let old_pl = OLD_PAYLOAD.len(); + // A rejected flow never commits, so its length is bounded by the confirm flow. + let n = run_flow(&root, &old_image, &bad_image, None, true, Health::Confirm).ops; + + for mode in CUT_MODES + { + for reset_applied in RESET_OUTCOMES + { + for k in 0..n + { + let out = run_flow( + &root, + &old_image, + &bad_image, + Some((k, mode)), + reset_applied, + Health::Confirm, + ); + // The image never verifies, so the swap is never confirmed. + assert!( + !out.shared.model.borrow().boots_bank2(), + "a rejected image must never boot the NEW bank" + ); + // The OLD bank still boots and still verifies. + assert!( + bank_verifies(&out.shared.model.borrow(), false, old_pl, &root), + "the OLD bank bytes must still verify after a rejection" + ); + // No swap staged, no record dangling, NVCNT never raised. + assert_eq!(out.shared.model.borrow().staged_swap(), None); + assert_eq!(pending_of(&out.shared), PendingFlag::None); + assert!( + nvcnt_of(&out.shared) <= OLD_BANK_COUNTER, + "a rejected image must not bump NVCNT" + ); + } + } + } +} + +#[test] +fn reset_after_clean_commit_boots_new_bank_and_confirms() +{ + // A clean run with no cut: commit stages the swap, the modelled reset applies + // it atomically, on_boot owes a confirm, confirm completes. Proves the + // confirmed new bank store verifies and the NVCNT bumped last. + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + let new_image = dev_image(NEW_IMAGE_COUNTER, NEW_PAYLOAD); + let out = run_flow( + &root, + &old_image, + &new_image, + None, + true, + Health::Confirm, + ); + assert!(out.shared.model.borrow().boots_bank2(), "the NEW bank boots"); + assert!( + bank_verifies(&out.shared.model.borrow(), true, NEW_PAYLOAD.len(), &root), + "the confirmed NEW bank bytes verify" + ); + assert_eq!(nvcnt_of(&out.shared), NEW_IMAGE_COUNTER, "NVCNT bumped last"); + assert_eq!(pending_of(&out.shared), PendingFlag::None, "record cleared"); +} + +#[test] +fn revert_returns_to_old_bank_and_leaves_no_dangling_stage() +{ + // Drive the revert branch: on_boot reaches AwaitingConfirm, the health check + // fails, so revert is driven. After a revert the OLD physical bank boots again + // and no staged swap dangles toward the unverified bank. + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + let new_image = dev_image(NEW_IMAGE_COUNTER, NEW_PAYLOAD); + let out = run_flow( + &root, + &old_image, + &new_image, + None, + true, + Health::Revert, + ); + assert!( + !out.shared.model.borrow().boots_bank2(), + "the OLD physical bank boots after a revert" + ); + assert!( + bank_verifies(&out.shared.model.borrow(), false, OLD_PAYLOAD.len(), &root), + "the OLD bank bytes still verify after a revert" + ); + assert_eq!(nvcnt_of(&out.shared), OLD_BANK_COUNTER, "NVCNT not bumped"); + assert_eq!(out.shared.model.borrow().staged_swap(), None, "no dangling stage"); + assert_eq!(pending_of(&out.shared), PendingFlag::None, "record cleared"); +} + +#[test] +fn cut_before_swap_reset_keeps_old_bank() +{ + // The swap is staged but the option load never commits (a cut before the + // reset). The OLD bank still boots and still verifies. + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + let new_image = dev_image(NEW_IMAGE_COUNTER, NEW_PAYLOAD); + let out = run_flow( + &root, + &old_image, + &new_image, + None, + false, + Health::Confirm, + ); + assert!( + !out.shared.model.borrow().boots_bank2(), + "a lost option load keeps the OLD bank booting" + ); + assert!( + bank_verifies(&out.shared.model.borrow(), false, OLD_PAYLOAD.len(), &root), + "the OLD bank bytes still verify" + ); + assert_eq!(pending_of(&out.shared), PendingFlag::None, "record cleared on boot"); +} + +#[test] +fn torn_page_write_makes_bank_fail_verify() +{ + // A torn quad-word during a payload page write poisons that quad-word, so the + // inactive bank fails verify and no swap is armed. The OLD bank is untouched. + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + // A payload larger than one page, so a full page flushes during receive where + // the tear lands, not only at accept. + let big_payload = vec![0xCDu8; 3 * fw_update::PAGE_LEN]; + let new_image = dev_image(NEW_IMAGE_COUNTER, &big_payload); + + let shared = seeded_shared(&old_image); + // Arm a torn write at the first payload program (index ERASE_OPS, just past + // the inactive-bank erase). + shared.model.borrow_mut().arm_cut(ERASE_OPS, CutMode::TornWrite); + + let se = LocalSeCounter::new(SE_AT_FLOOR); + let driver = Stm32FlashSeam::new(shared.clone()); + let mut up: CensusUpdater = Updater::new(&root, driver, se); + up.begin(new_image.len()).expect("begin"); + + // The torn write faults the page write during receive, collapsing the + // transfer fail-closed. + let streamed = stream_chunks(&mut up, &new_image); + assert!(!streamed, "the torn payload write must fault the transfer"); + assert!(shared.model.borrow().cut_fired(), "the tear fired"); + assert_ne!(up.state(), UpdateState::Committed); + assert_eq!(shared.model.borrow().staged_swap(), None, "no swap staged"); + drop(up); + + // The inactive bank (physical Bank 2) fails verify: the tear poisoned it. + assert!( + !bank_verifies(&shared.model.borrow(), true, big_payload.len(), &root), + "the poisoned inactive bank must fail verify" + ); + // The OLD bank store (physical Bank 1) is untouched and still bootable. + assert!( + bank_verifies(&shared.model.borrow(), false, OLD_PAYLOAD.len(), &root), + "the OLD bank store must be untouched by an inactive-bank tear" + ); +} + +#[test] +fn se_spend_interrupted_does_not_double_spend_or_strand() +{ + // Interrupt confirm at the SE spend (the channel drops on se.update), then + // prove the recovery on the next boot does not double-spend the SE counter and + // does not strand a half-confirmed state. The machine spends the SE first in + // confirm, so a drop there leaves the swap committed, the record still Armed, + // and the NVCNT not yet bumped. + let root = dev_root(); + let old_image = dev_image(OLD_BANK_COUNTER, OLD_PAYLOAD); + let new_image = dev_image(NEW_IMAGE_COUNTER, NEW_PAYLOAD); + + let shared = seeded_shared(&old_image); + let se = SharedSe::new(SE_AT_FLOOR); + + // Run to a clean post-reset AwaitingConfirm state (no flash cut). + { + let driver = Stm32FlashSeam::new(shared.clone()); + let mut up = Updater::new(&root, driver, se.clone()); + up.begin(new_image.len()).expect("begin"); + let mut offset = 0usize; + for chunk in new_image.chunks(13) + { + up.receive_chunk(offset, chunk).expect("receive"); + offset += chunk.len(); + } + up.verify_and_accept().expect("accept"); + up.commit().expect("commit"); + } + shared.model.borrow_mut().apply_reset(); + assert!(shared.model.borrow().boots_bank2(), "the swap took effect"); + + // First confirm boot: arm a channel drop on the SE update. The spend faults. + se.arm_drop(); + { + let driver = Stm32FlashSeam::new(shared.clone()); + let mut up = Updater::new(&root, driver, se.clone()); + assert_eq!(up.on_boot().expect("on_boot"), UpdateState::AwaitingConfirm); + assert!(up.confirm(NEW_IMAGE_COUNTER).is_err(), "the spend faults"); + } + assert!(!se.updated(), "a dropped SE spend must not decrement the counter"); + assert_eq!(se.value(), SE_AT_FLOOR, "the counter did not decrement"); + // The swap is still committed, the record still Armed, NVCNT not bumped. + assert!(shared.model.borrow().boots_bank2(), "swap stays committed"); + assert!( + matches!(pending_of(&shared), PendingFlag::Armed(_)), + "the confirm-owed record survives a dropped spend" + ); + assert_eq!(nvcnt_of(&shared), BASELINE_NVCNT, "NVCNT not bumped yet"); + + // Next boot, channel up: the recovery re-enters AwaitingConfirm and confirms + // cleanly. The SE counter spends exactly once (no double spend). + { + let driver = Stm32FlashSeam::new(shared.clone()); + let mut up = Updater::new(&root, driver, se.clone()); + assert_eq!(up.on_boot().expect("on_boot"), UpdateState::AwaitingConfirm); + up.confirm(NEW_IMAGE_COUNTER).expect("confirm"); + } + assert!(se.updated(), "the recovery spends the SE once"); + assert_eq!(se.value(), SE_AT_FLOOR - 1, "spent exactly once"); + assert!(shared.model.borrow().boots_bank2(), "NEW bank confirmed"); + assert_eq!(pending_of(&shared), PendingFlag::None, "record cleared"); + assert_eq!(nvcnt_of(&shared), NEW_IMAGE_COUNTER, "NVCNT bumped last"); +} diff --git a/crates/mcu-flash/src/regs.rs b/crates/mcu-flash/src/regs.rs index 697c4da..62e2cd7 100644 --- a/crates/mcu-flash/src/regs.rs +++ b/crates/mcu-flash/src/regs.rs @@ -1,24 +1,22 @@ //! Hand-rolled, cited register and geometry definitions for the embedded flash. //! -//! ONLY the FLASH controller registers, key values, and bank geometry the -//! dual-bank update path touches are defined here, each with an RM0456 ch.7 -//! citation. +//! Only the FLASH controller registers, key values, and bank geometry the dual-bank +//! update path touches are defined here, each with an RM0456 ch.7 citation. //! -//! Addresses use the SECURE alias because this driver runs in the secure state -//! and writes the secure bank: a secure-world driver uses the SEC register bank -//! exclusively (RM0456 sec 7.7 Table 72). Every ADDRESS, BIT POSITION, KEY -//! VALUE, and GEOMETRY constant here is pinned to a hard-coded primary-source -//! literal in `regs_pin_tests`. +//! Addresses use the secure alias because this driver runs in the secure state and +//! writes the secure bank: a secure-world driver uses the SEC register bank +//! exclusively (RM0456 sec 7.7 Table 72). Every address, bit position, key value, +//! and geometry constant here is pinned to a hard-coded primary-source literal in +//! `regs_pin_tests`. //! //! # The physical-bank-versus-mapped-address contract (RM0456 sec 7.5.8) //! -//! SWAP_BANK remaps the ADDRESS of each bank. It does NOT move the BKER erase -//! selector, the SECWM, or the WRP, which all follow the PHYSICAL bank (RM0456 -//! sec 7.5.8 Fig 23/24). A driver that wants to act on physical bank X must -//! therefore use TWO different facts that diverge under SWAP_BANK=1: the erase -//! BKER bit is SWAP_BANK-independent, while the program / read address is -//! SWAP_BANK-derived. [`PhysBank`] folds both into one place so erase and -//! program always agree on the same physical bank. +//! SWAP_BANK remaps the address of each bank. It does not move the BKER erase +//! selector, the SECWM, or the WRP, which all follow the physical bank (RM0456 sec +//! 7.5.8 Fig 23/24). A driver acting on physical bank X must therefore use two facts +//! that diverge under SWAP_BANK=1: the erase BKER bit is SWAP_BANK-independent, while +//! the program / read address is SWAP_BANK-derived. [`PhysBank`] folds both into one +//! place so erase and program always agree on the same physical bank. // =========================================================================== // FLASH controller register block. Secure alias base 0x5002_2000, non-secure @@ -68,6 +66,18 @@ pub(crate) const FLASH_SECCR: u32 = FLASH_BASE + FLASH_SECCR_OFF; /// `FLASH_OPTR` absolute address. pub(crate) const FLASH_OPTR: u32 = FLASH_BASE + FLASH_OPTR_OFF; +/// `FLASH_SECWM1R1` offset 0x50 (physical Bank 1 secure watermark). RM0456 sec +/// 7.9.17. Secure-read-only: a non-secure read is RAZ. +pub(crate) const FLASH_SECWM1R1_OFF: u32 = 0x50; +/// `FLASH_SECWM2R1` offset 0x60 (physical Bank 2 secure watermark). RM0456 sec +/// 7.9.21. Secure-read-only: a non-secure read is RAZ. +pub(crate) const FLASH_SECWM2R1_OFF: u32 = 0x60; +/// `FLASH_SECWM1R1` absolute secure address (0x5002_2050). The boot stage decodes +/// PSTRT (bits [4:0]) and PEND (bits [20:16]) from the word this reads back. +pub(crate) const FLASH_SECWM1R1: u32 = FLASH_BASE + FLASH_SECWM1R1_OFF; +/// `FLASH_SECWM2R1` absolute secure address (0x5002_2060). +pub(crate) const FLASH_SECWM2R1: u32 = FLASH_BASE + FLASH_SECWM2R1_OFF; + // =========================================================================== // CR / OPT unlock keys. A wrong value or order locks the CR until reset. // =========================================================================== @@ -101,7 +111,7 @@ pub(crate) const SECCR_PNB_SHIFT: u32 = 3; /// `SECCR.PNB` field mask (bits [10:3]). RM0456 sec 7.9.10. pub(crate) const SECCR_PNB_MASK: u32 = 0xFF << SECCR_PNB_SHIFT; /// `SECCR.BKER` bit 11: erase bank select (0 Bank1, 1 Bank2). RM0456 sec -/// 7.9.10. BKER selects the PHYSICAL bank, SWAP_BANK does not move it (RM0456 +/// 7.9.10. BKER selects the physical bank, SWAP_BANK does not move it (RM0456 /// sec 7.5.8). pub(crate) const SECCR_BKER: u32 = 1 << 11; /// `SECCR.BWR` bit 14: burst-write request. Defined to fix its position. The @@ -200,17 +210,35 @@ pub(crate) const PAGES_PER_BANK: u32 = 32; /// Bytes per bank under DUALBANK=1 (256 KB). RM0456 sec 7.3.1 Table 51. pub(crate) const BANK_SIZE: u32 = PAGE_SIZE * PAGES_PER_BANK; -/// The LOW secure alias base (the boot / active range). RM0456 sec 7.3.1 Table +/// The low secure alias base (the boot / active range). RM0456 sec 7.3.1 Table /// 51, AN5347 Table 2. SECBOOTADD0 points here, so whichever physical bank /// SWAP_BANK maps low is the bank that boots (RM0456 sec 7.5.8). pub(crate) const LOW_ALIAS_BASE: u32 = 0x0C00_0000; -/// The HIGH secure alias base (the staging / inactive range). RM0456 sec 7.3.1 +/// The high secure alias base (the staging / inactive range). RM0456 sec 7.3.1 /// Table 51 (Bank 2 page 0 at 0x0C04_0000 secure for the 512 KB STM32U545), /// AN5347 Table 2. The two 256 KB ranges are contiguous (the 512 KB part keeps /// DUALBANK=1 contiguous, not the 0x0802_0000 split of the smaller variants in /// the Table 51 footnote). pub(crate) const HIGH_ALIAS_BASE: u32 = LOW_ALIAS_BASE + BANK_SIZE; +/// The offset from a non-secure flash alias to its secure alias. RM0456 sec 2.3 +/// memory map, AN5347 Table 2: the secure view of flash sits 0x0400_0000 above +/// the non-secure view, so a secure alias address minus this offset is the +/// non-secure alias of the same physical byte. +pub(crate) const SECURE_ALIAS_OFFSET: u32 = 0x0400_0000; +/// The low non-secure alias base. RM0456 sec 2.3, AN5347 Table 2. +/// +/// Consumed by the host FLASH-controller model to emulate the non-secure alias +/// view. The driver derives an NS-band address from a swap-mapped secure base +/// via [`SECURE_ALIAS_OFFSET`], so this fixed base is test-only today. +#[cfg(test)] +pub(crate) const NS_LOW_ALIAS_BASE: u32 = LOW_ALIAS_BASE - SECURE_ALIAS_OFFSET; +/// The high non-secure alias base. RM0456 sec 2.3, AN5347 Table 2. +/// +/// Consumed by the host FLASH-controller model (see [`NS_LOW_ALIAS_BASE`]). +#[cfg(test)] +pub(crate) const NS_HIGH_ALIAS_BASE: u32 = HIGH_ALIAS_BASE - SECURE_ALIAS_OFFSET; + /// The flash program granularity in bytes: a quad-word is 4 x 32-bit words. /// RM0456 sec 7.3.7. A program writes one whole quad-word, a sub-quad-word /// write raises SIZERR. @@ -226,18 +254,18 @@ pub(crate) const ERASED_WORD: u32 = 0xFFFF_FFFF; // =========================================================================== // Physical bank selector: the single B1 helper. // -// RM0456 sec 7.5.8 Fig 23/24: SWAP_BANK remaps the bank ADDRESS but NOT the -// BKER erase selector, which always names the physical bank. Folding both into -// one type forces erase (BKER) and program / read (address) to agree on the -// same physical bank. +// RM0456 sec 7.5.8 Fig 23/24: SWAP_BANK remaps the bank address but not the BKER +// erase selector, which always names the physical bank. Folding both into one type +// forces erase (BKER) and program / read (address) to agree on the same physical +// bank. // =========================================================================== /// One of the two physical flash banks. /// -/// A PHYSICAL bank is a fixed silicon region. Its erase selector (BKER) never -/// moves, while its mapped address depends on SWAP_BANK (RM0456 sec 7.5.8). The -/// driver names the physical bank with this type, then asks for the BKER bit and -/// the mapped base together so the two can never diverge. +/// A physical bank is a fixed silicon region. Its erase selector (BKER) never moves, +/// while its mapped address depends on SWAP_BANK (RM0456 sec 7.5.8). The driver names +/// the physical bank with this type, then asks for the BKER bit and the mapped base +/// together so the two can never diverge. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PhysBank { @@ -279,6 +307,99 @@ impl PhysBank } } +// =========================================================================== +// Page security band: the controller register bank plus the address alias a +// page must be driven through. +// +// RM0456 Table 68: a secure-controller access to a non-secure page is RAZ on read +// and Write-Ignored plus WRPERR on program / erase, and the non-secure controller +// sees a secure page the same way. So each page is driven through the controller +// (SEC* vs NS*) and the alias (0x0C.. vs 0x08..) matching its SECWM label. FLASH_NSCR +// is documented RW from both states (RM0456 sec 7.9.9), so secure firmware may drive +// the non-secure controller for the non-secure image pages. The NSCR program / erase +// bits (PG, PER, PNB, BKER, STRT, LOCK) share the SECCR bit positions (RM0456 sec +// 7.9.9 / 7.9.10), so the `SECCR_*` bit constants apply to NSCR, and the SR flags +// (RM0456 sec 7.9.7 / 7.9.8) share positions across NSSR and SECSR. +// =========================================================================== + +/// The SECWM security band of a flash page, which selects the controller +/// register bank and the address alias the driver must use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PageBand +{ + /// A secure page (0..=`SECWM_PEND`): SEC* registers, 0x0C.. alias. + Secure, + /// A non-secure page (`SECWM_PEND`+1..): NS* registers, 0x08.. alias. + NonSecure, +} + +impl PageBand +{ + /// The CR-unlock key register for this band (SECKEYR or NSKEYR). + pub(crate) const fn keyr(self) -> u32 + { + match self + { + PageBand::Secure => FLASH_SECKEYR, + PageBand::NonSecure => FLASH_NSKEYR, + } + } + + /// The status register for this band (SECSR or NSSR). + pub(crate) const fn sr(self) -> u32 + { + match self + { + PageBand::Secure => FLASH_SECSR, + PageBand::NonSecure => FLASH_NSSR, + } + } + + /// The control register for this band (SECCR or NSCR). + pub(crate) const fn cr(self) -> u32 + { + match self + { + PageBand::Secure => FLASH_SECCR, + PageBand::NonSecure => FLASH_NSCR, + } + } + + /// The alias base for this band, given the SECURE-alias mapped base of the + /// target physical bank. + /// + /// RM0456 sec 2.3 / AN5347 Table 2: the non-secure alias sits + /// [`SECURE_ALIAS_OFFSET`] below the secure one, so a secure-band access + /// keeps the secure base and a non-secure-band access drops to the NS alias. + pub(crate) const fn alias_base(self, secure_mapped_base: u32) -> u32 + { + match self + { + PageBand::Secure => secure_mapped_base, + PageBand::NonSecure => secure_mapped_base - SECURE_ALIAS_OFFSET, + } + } +} + +/// The security band of a bank-relative page index, from the SECWM boundary. +/// +/// RM0456 sec 7.9.17 / 7.9.21: pages 0..=`SECWM_PEND` are secure, the rest are +/// non-secure. The host FLASH-controller model uses this to decide a page's RAZ +/// / WRPERR behaviour. The driver routes by byte offset against the sub-band +/// size, so this page-indexed helper is test-only today. +#[cfg(test)] +pub(crate) const fn page_band(page: u32) -> PageBand +{ + if page <= SECWM_PEND + { + PageBand::Secure + } + else + { + PageBand::NonSecure + } +} + /// Decodes `OPTR.SWAP_BANK` into a plain bool (true = SWAP_BANK set). pub(crate) const fn swap_bank_set(optr: u32) -> bool { @@ -314,18 +435,42 @@ pub(crate) const fn inactive_phys_bank(swap: bool) -> PhysBank } // =========================================================================== -// Per-bank layout (the M2 reconciliation). docs grounding: each 256 KB bank is -// split [boot metadata | boot-stage + key | secure app | NSC veneer | NS app]. -// This driver only addresses the boot-metadata band and the A/B image band. The -// boot-stage, key, and application bands are placed by the linker of a future -// crate, not here. +// Per-bank layout. +// Both 256 KB banks carry the identical internal split, so the address-space view is +// the same before and after a swap and the SAU / SECWM / MPU never reprogram on a +// swap: +// pages 0-1 (16 KB) secure boot metadata (physical Bank 1 only) +// pages 2-8 (56 KB) secure boot stage, immutable, base = SECBOOTADD0 +// page 9 (8 KB) secure image descriptor: header [0:24], signature [24:88] +// pages 10-18 (72 KB) secure secure app +// page 19 (8 KB) secure NSC veneer (.gnu.sgstubs), 0x0C026000 +// pages 20-31 (96 KB) non-secure non-secure app +// +// The signed image file stays contiguous HEADER || PAYLOAD || SIGNATURE. The updater +// de-interleaves that stream onto flash: the header lands in the descriptor page +// [0:24], the payload (the raw firmware, secure app then NS app) lands page-aligned +// at its link origin starting on page 10 (0x0C014000), and the signature lands in +// the descriptor page [24:88]. So the committed bank is bootable-shaped: the secure +// app vector table sits at its link origin, not the header magic. RM0456 sec 7.5.8 +// (identical layout per bank). // -// The NVCNT, the pending record, the boot-count, and the update-outcome record -// live in pages 0-1 of PHYSICAL Bank 1, the FIXED metadata band (RM0456 sec -// 7.5.8: protections follow the physical bank, so SECWM1 / WRP1 / HDP1 cover it -// permanently). The driver re-derives the MAPPED address of physical Bank 1 from +// This driver addresses the boot-metadata band and the A/B image band (pages 9-31): +// the descriptor page 9 and the payload pages 10-31. The boot stage (pages 2-8) and +// the metadata (pages 0-1) are outside the image band, so the updater never erases +// or programs them. The boot-stage crate linker owns pages 2-8, not this driver. +// +// The NVCNT, the pending record, the boot-count, and the update-outcome record live +// in pages 0-1 of physical Bank 1, the fixed metadata band (RM0456 sec 7.5.8: +// protections follow the physical bank, so SECWM1 / WRP1 / HDP1 cover it +// permanently). The driver re-derives the mapped address of physical Bank 1 from // SWAP_BANK on every metadata access, so the record survives a swap. There is a -// SINGLE copy, no per-bank duplicate. +// single copy, no per-bank duplicate. +// +// The image band spans the SECWM boundary: pages 9-19 are secure, pages 20-31 +// non-secure. RM0456 Table 68 forbids the secure controller from reading or writing a +// non-secure page (RAZ on read, Write-Ignored plus WRPERR on program / erase), so the +// driver drives each page through the controller and alias matching its [`PageBand`]. +// SECWM PEND = 19 pins the boundary. // =========================================================================== /// The first metadata page index inside physical Bank 1 (pages 0-1). @@ -333,28 +478,78 @@ pub(crate) const META_PAGE_FIRST: u32 = 0; /// The number of metadata pages reserved at the bottom of physical Bank 1. /// /// Two 8 KB pages (16 KB) carry the NVCNT log, the pending record, the -/// boot-count log, and the update-outcome record. The image band starts after -/// them. +/// boot-count log, and the update-outcome record. The boot-stage band starts +/// after them. pub(crate) const META_PAGE_COUNT: u32 = 2; -/// The image band start page inside each bank (just past the metadata band). +/// The first immutable boot-stage page (page 2). RM0456 sec 7.5.8, Table 26 +/// (SECBOOTADD0 = 0x0C004000). Pages 2-8 hold the boot stage, OUTSIDE the image +/// band, so the updater never touches them. +pub(crate) const BOOT_STAGE_PAGE_FIRST: u32 = META_PAGE_FIRST + META_PAGE_COUNT; +/// The number of immutable boot-stage pages (pages 2-8, 56 KB). +pub(crate) const BOOT_STAGE_PAGE_COUNT: u32 = 7; + +/// The last SECURE page index in each bank (inclusive), the SECWM PEND value. /// -/// The A/B image occupies pages [`IMAGE_PAGE_FIRST`]..[`PAGES_PER_BANK`] of the -/// inactive bank. The metadata band (pages 0-1 of physical Bank 1) is never an -/// image page, so the image write / erase loop never touches NVCNT. -pub(crate) const IMAGE_PAGE_FIRST: u32 = META_PAGE_FIRST + META_PAGE_COUNT; -/// The number of image pages per bank (the pages after the metadata band). -pub(crate) const IMAGE_PAGE_COUNT: u32 = PAGES_PER_BANK - IMAGE_PAGE_FIRST; -/// The image band size per bank in bytes. +/// RM0456 sec 7.9.17 / 7.9.21: pages 0..=`SECWM_PEND` are secure, the rest are +/// non-secure. Pages 20-31 are the non-secure application. This is the one boundary +/// that decides a page's [`PageBand`]. +pub(crate) const SECWM_PEND: u32 = 19; + +/// The image band start page inside each bank (just past the boot-stage band). /// -/// This is the span the A/B update writes. It is the bank minus the 16 KB -/// metadata band, so the image region no longer silently shrinks under a -/// top-of-bank carve-out, it is the explicit lower-pages span the layout -/// reserves. +/// The A/B image occupies pages [`IMAGE_PAGE_FIRST`]..[`PAGES_PER_BANK`] (pages +/// 9-31) of the inactive bank. Page 9 is the descriptor, pages 10-31 the payload. +/// The metadata band (pages 0-1) and the boot-stage band (pages 2-8) are never +/// image pages, so the image write / erase loop never touches NVCNT or the +/// immutable boot stage. +pub(crate) const IMAGE_PAGE_FIRST: u32 = + BOOT_STAGE_PAGE_FIRST + BOOT_STAGE_PAGE_COUNT; +/// The number of image pages per bank (the pages after the boot-stage band). +pub(crate) const IMAGE_PAGE_COUNT: u32 = PAGES_PER_BANK - IMAGE_PAGE_FIRST; +/// The image band size per bank in bytes (pages 9-31, 184 KB). pub(crate) const IMAGE_REGION_SIZE: u32 = IMAGE_PAGE_COUNT * PAGE_SIZE; -/// The byte offset of the image band from a bank base. +/// The byte offset of the image band from a bank base (page 9, 0x12000). pub(crate) const IMAGE_REGION_OFFSET: u32 = IMAGE_PAGE_FIRST * PAGE_SIZE; +// The image descriptor occupies page 9 (the first image page). It holds the signed +// image's header at byte offset [0:24] and its signature at [24:88], the rest of the +// page erased. The header magic therefore lands here, never on the secure app link +// origin, so the committed bank boots. + +/// The descriptor page byte offset from a bank base (page 9, 0x12000). +pub(crate) const IMAGE_DESCRIPTOR_OFFSET: u32 = IMAGE_REGION_OFFSET; +/// The descriptor bytes the verify path reads back: a 24-byte header followed by +/// a 64-byte signature (`image_verify::HEADER_LEN` + `image_verify::SIG_LEN`). +/// The rest of page 9 stays erased. +pub(crate) const IMAGE_DESCRIPTOR_LEN: u32 = 88; + +// The payload occupies pages 10-31, page-aligned at the secure app link origin. It +// splits at the SECWM boundary into a secure sub-band (pages 10-19) and a non-secure +// sub-band (pages 20-31). The verify path reads each sub-band through the alias +// matching its band, so a non-secure page is never read through the secure alias +// (which would return RAZ, Table 68). + +/// The first payload page inside each bank (page 10, one past the descriptor). +pub(crate) const IMAGE_PAYLOAD_PAGE_FIRST: u32 = IMAGE_PAGE_FIRST + 1; +/// The payload byte offset from a bank base (page 10, 0x14000, the secure app +/// link origin 0x0C014000). +pub(crate) const IMAGE_PAYLOAD_OFFSET: u32 = IMAGE_PAYLOAD_PAGE_FIRST * PAGE_SIZE; +/// The payload size per bank in bytes (pages 10-31, 176 KB): the image band less +/// the one descriptor page. +pub(crate) const IMAGE_PAYLOAD_SIZE: u32 = IMAGE_REGION_SIZE - PAGE_SIZE; +/// The secure payload sub-band size in bytes (pages 10-19, 80 KB, 0x14000). +pub(crate) const IMAGE_PAYLOAD_SECURE_SIZE: u32 = + (IMAGE_NS_PAGE_FIRST - IMAGE_PAYLOAD_PAGE_FIRST) * PAGE_SIZE; + +/// The first non-secure image page (page 20, one past SECWM PEND). +pub(crate) const IMAGE_NS_PAGE_FIRST: u32 = SECWM_PEND + 1; +/// The non-secure image sub-band byte offset from a bank base (page 20, 0x28000). +pub(crate) const IMAGE_NS_BAND_OFFSET: u32 = IMAGE_NS_PAGE_FIRST * PAGE_SIZE; +/// The non-secure image sub-band size in bytes (pages 20-31, 96 KB, 0x18000). +pub(crate) const IMAGE_NS_BAND_SIZE: u32 = + (PAGES_PER_BANK - IMAGE_NS_PAGE_FIRST) * PAGE_SIZE; + // Metadata record offsets inside physical Bank 1. // // Page 0 holds the append-only logs (NVCNT, boot-count). Page 1 holds the @@ -402,7 +597,7 @@ pub(crate) const BOOT_TICK: u32 = 0xA5A5_5A5A; pub(crate) const OUTCOME_NONE: u32 = 0xFFFF_FFFF; /// Update-outcome encoding: an auto-revert happened. /// -/// A future boot-stage SETS this on an auto-revert so the event is NOT silent: a +/// A future boot-stage sets this on an auto-revert so the event is not silent: a /// later host tool reads it back and surfaces it. This driver only reserves the /// region and provides the read / write / clear seam. The LED and host-CLI /// surfacing is future work. diff --git a/crates/mcu-flash/src/regs_pin_tests.rs b/crates/mcu-flash/src/regs_pin_tests.rs index 8e14cd0..8001816 100644 --- a/crates/mcu-flash/src/regs_pin_tests.rs +++ b/crates/mcu-flash/src/regs_pin_tests.rs @@ -1,10 +1,9 @@ //! Ground-truth pinning tests for `regs`. //! -//! Every assertion compares a symbolic constant against a HARD-CODED -//! primary-source LITERAL, never against another symbol or an expression built -//! from other symbols. -//! The source is RM0456 ch.7 (registers, sequences, geometry) -//! plus AN5347 Table 2 (the secure-alias offset). +//! Every assertion compares a symbolic constant against a hard-coded primary-source +//! literal, never against another symbol or an expression built from other symbols. +//! The source is RM0456 ch.7 (registers, sequences, geometry) plus AN5347 Table 2 +//! (the secure-alias offset). use super::*; @@ -94,6 +93,15 @@ fn optr_bits_are_canonical() assert_eq!(OPTR_TZEN, 0x8000_0000, "OPTR.TZEN bit31"); } +#[test] +fn secwm_readback_registers_are_canonical() +{ + // RM0456 sec 7.9.17 (SECWM1R1, offset 0x50) / sec 7.9.21 (SECWM2R1, offset + // 0x60), secure alias base 0x5002_2000. + assert_eq!(FLASH_SECWM1R1, 0x5002_2050, "FLASH_SECWM1R1 secure address"); + assert_eq!(FLASH_SECWM2R1, 0x5002_2060, "FLASH_SECWM2R1 secure address"); +} + #[test] fn geometry_is_canonical() { @@ -161,23 +169,70 @@ fn running_and_inactive_bank_track_swap() #[test] fn image_band_layout_is_canonical() { - // The metadata band is pages 0-1, the image band is pages 2-31, all HARD - // literals so a layout change must be re-pinned deliberately. + // Layout L1 descriptor-page form, all hard + // literals so a layout change must be re-pinned deliberately: + // pages 0-1 metadata (physical Bank 1 only) + // pages 2-8 immutable boot stage (SECBOOTADD0 = 0x0C004000) + // page 9 image descriptor (header [0:24], signature [24:88]) + // pages 10-19 secure app + NSC veneer + // pages 20-31 non-secure app assert_eq!(META_PAGE_FIRST, 0, "metadata band first page"); assert_eq!(META_PAGE_COUNT, 2, "metadata band 2 pages (16 KB)"); - assert_eq!(IMAGE_PAGE_FIRST, 2, "image band first page"); - assert_eq!(IMAGE_PAGE_COUNT, 30, "image band 30 pages"); - assert_eq!(IMAGE_REGION_OFFSET, 0x0000_4000, "image band offset 16 KB"); - // 30 pages of 8 KB. The literal atom, not a 30 * 0x2000 expression. - assert_eq!(IMAGE_REGION_SIZE, 0x0003_C000, "image band 240 KB"); + assert_eq!(BOOT_STAGE_PAGE_FIRST, 2, "boot-stage band first page"); + assert_eq!(BOOT_STAGE_PAGE_COUNT, 7, "boot-stage band 7 pages (56 KB)"); + assert_eq!(IMAGE_PAGE_FIRST, 9, "image band first page"); + assert_eq!(IMAGE_PAGE_COUNT, 23, "image band 23 pages"); + assert_eq!(IMAGE_REGION_OFFSET, 0x0001_2000, "image band offset page 9"); + // 23 pages of 8 KB. The literal atom, not a 23 * 0x2000 expression. + assert_eq!(IMAGE_REGION_SIZE, 0x0002_E000, "image band 184 KB"); + + // The descriptor occupies page 9, holding the 24-byte header and 64-byte + // signature at the front. + assert_eq!(IMAGE_DESCRIPTOR_OFFSET, 0x0001_2000, "descriptor offset page 9"); + assert_eq!(IMAGE_DESCRIPTOR_LEN, 88, "descriptor 88 bytes (header + sig)"); + + // The payload occupies pages 10-31, splitting at the SECWM boundary into a + // secure sub-band (pages 10-19) and a non-secure sub-band (pages 20-31). + assert_eq!(SECWM_PEND, 19, "SECWM last secure page (inclusive)"); + assert_eq!(IMAGE_PAYLOAD_PAGE_FIRST, 10, "first payload page"); + assert_eq!(IMAGE_PAYLOAD_OFFSET, 0x0001_4000, "payload offset page 10"); + assert_eq!(IMAGE_PAYLOAD_SIZE, 0x0002_C000, "payload 176 KB"); + assert_eq!(IMAGE_PAYLOAD_SECURE_SIZE, 0x0001_4000, "secure payload 80 KB"); + assert_eq!(IMAGE_NS_PAGE_FIRST, 20, "first non-secure image page"); + assert_eq!(IMAGE_NS_BAND_OFFSET, 0x0002_8000, "non-secure sub-band offset"); + assert_eq!(IMAGE_NS_BAND_SIZE, 0x0001_8000, "non-secure sub-band 96 KB"); + // The descriptor page plus the payload tile the image band exactly. + assert_eq!( + PAGE_SIZE + IMAGE_PAYLOAD_SIZE, + IMAGE_REGION_SIZE, + "descriptor page plus payload tile the image band" + ); + // The two payload sub-bands tile the payload exactly, no gap or overlap. + assert_eq!( + IMAGE_PAYLOAD_SECURE_SIZE + IMAGE_NS_BAND_SIZE, + IMAGE_PAYLOAD_SIZE, + "payload sub-bands tile the payload" + ); + // The secure payload begins one page after the descriptor and ends where the + // non-secure sub-band begins. + assert_eq!( + IMAGE_DESCRIPTOR_OFFSET + PAGE_SIZE, + IMAGE_PAYLOAD_OFFSET, + "payload begins one page after the descriptor" + ); + assert_eq!( + IMAGE_PAYLOAD_OFFSET + IMAGE_PAYLOAD_SECURE_SIZE, + IMAGE_NS_BAND_OFFSET, + "secure payload ends where the non-secure sub-band begins" + ); } #[test] fn metadata_layout_is_canonical() { - // The metadata records live at fixed offsets inside PHYSICAL Bank 1, pinned - // to HARD literal byte offsets (the driver re-derives the live alias address - // from SWAP_BANK on every access). + // The metadata records live at fixed offsets inside physical Bank 1, pinned to + // hard literal byte offsets (the driver re-derives the live alias address from + // SWAP_BANK on every access). assert_eq!(META_NVCNT_OFFSET, 0x0000_0000, "NVCNT log offset"); assert_eq!(META_BOOT_OFFSET, 0x0000_1000, "boot-count log offset"); assert_eq!(META_PENDING_OFFSET, 0x0000_2000, "pending record offset"); diff --git a/crates/nonsecure/memory.x b/crates/nonsecure/memory.x index 78b8874..9b47d3f 100644 --- a/crates/nonsecure/memory.x +++ b/crates/nonsecure/memory.x @@ -1,10 +1,14 @@ /* Non-secure (TZ-NS) memory layout for cortex-m-rt's link.x. * - * FLASH is the NS flash bank (Bank 2 NS alias) at 0x0804_0000, 256 KB. RAM is - * the upper 64 KB of SRAM1 at 0x2002_0000, the provisional NS half matching the - * partition map's SRAM1 split (SAU region 1 + region 2 in platform's map.rs). - * The secure world hands off here by pointing SCB_NS->VTOR at this FLASH base. - * RM0456 memory map. + * FLASH is the non-secure app band, pages 20-31 of a 256 KB bank, at the low NS + * alias 0x0802_8000, LENGTH 96 KB (12 pages x 8 KB). The active bank presents + * this band at the low NS alias, the inactive bank at the high NS alias + * 0x0806_8000, so SAU never changes on a bank swap (SAU region 1 covers the + * whole NS flash alias, see platform map.rs). RAM is the upper 64 KB of SRAM1 at + * 0x2002_0000, the NS half matching the partition map's SRAM1 split (SAU region + * 2 in platform's map.rs). The secure world hands off here by pointing + * SCB_NS->VTOR at this FLASH base. RM0456 memory map (sec 7.5.8 identical layout + * per bank). * * SHARED_OUT is the pinned non-secure shared OUTPUT window: the top 1 KiB of the * NS half, carved out of RAM so no stack, static, or embassy allocation can land @@ -20,7 +24,7 @@ */ MEMORY { - FLASH (rx) : ORIGIN = 0x08040000, LENGTH = 256K + FLASH (rx) : ORIGIN = 0x08028000, LENGTH = 96K RAM (rwx) : ORIGIN = 0x20020000, LENGTH = 63K SHARED_OUT (rw) : ORIGIN = 0x2002FC00, LENGTH = 1K } diff --git a/crates/platform/src/map.rs b/crates/platform/src/map.rs index 0c614cd..f4af24c 100644 --- a/crates/platform/src/map.rs +++ b/crates/platform/src/map.rs @@ -1,12 +1,11 @@ //! The partition MAP: every address, region, pin and channel assignment as a -//! named, cited constant. This is the single place the device's security layout -//! is declared, and the sequence in `partition` only consumes these. +//! named, cited constant. This is the place the device's security layout +//! is declared, and the sequence in `partition` consumes these. //! //! Source anchors are RM0456 (memory map and per-peripheral register sections), //! AN5347 (TrustZone bring-up application note), the Armv8-M Architecture Reference //! Manual (SAU region encoding), and the board pin map (SE SPI1 on PA4-7 + PB1, -//! USB on PA11/PA12, TSC on PB4/PB6). Where a value is PROVISIONAL (open decision), -//! it is marked so and kept easy to retune. +//! USB on PA11/PA12, TSC on PB4/PB6). use crate::error::PartitionError; use crate::regs::SAU_ALIGN_MASK; @@ -18,9 +17,9 @@ use crate::regs::SAU_RLAR_NSC; // // SRAM1 is 192 KB at 0x2000_0000 (MPCBB1, 384 blocks of 512 B, 12 super-blocks). // The LOWER 128 KB is provisionally secure, the UPPER 64 KB non-secure. -// This is a TUNABLE skeleton value, not a final security decision. It drives both -// SAU region 2 (CPU view) and MPCBB1 SECCFGR8..11 (DMA/bus view), which MUST stay -// consistent. Retune both together when the real secure-RAM budget is known. +// This is a tunable skeleton value, not a final security decision. It drives both +// SAU region 2 (CPU view) and MPCBB1 SECCFGR8..11 (DMA/bus view). +// Retune both together when the real secure-RAM budget is known. // =========================================================================== /// SRAM1 base address. RM0456 memory map. @@ -64,14 +63,29 @@ pub(crate) const MPCBB4_CFGLOCK_MASK: u32 = 1; // the top 8 KB of secure Bank 1, where the toolchain places `.gnu.sgstubs`. // =========================================================================== -/// NSC veneer window base: top 8 KB of secure Bank 1 (.gnu.sgstubs lands here). -pub(crate) const NSC_VENEER_BASE: u32 = 0x0C03_E000; -/// NSC veneer window inclusive limit (8 KB). -pub(crate) const NSC_VENEER_LIMIT: u32 = 0x0C03_FFFF; +/// NSC veneer window base: page 19 of the secure app band (.gnu.sgstubs lands +/// here). Layout L1 moves the veneer from the top of the bank to page 19, the +/// top page of the secure image sub-band. RM0456 sec 7.5.8 identical-per-bank +/// layout. Matches crates/secure/memory.x + build.rs `--section-start`. +pub(crate) const NSC_VENEER_BASE: u32 = 0x0C02_6000; +/// NSC veneer window inclusive limit (8 KB, page 19). +pub(crate) const NSC_VENEER_LIMIT: u32 = 0x0C02_7FFF; -/// Non-secure flash (Bank 2) base. RM0456 memory map. -pub(crate) const FLASH_NS_BASE: u32 = 0x0804_0000; -/// Non-secure flash inclusive limit (256 KB). RM0456 memory map. +/// Non-secure flash alias base: the whole non-secure flash alias, not just the +/// high bank. +/// +/// (RM0456 sec 2.2 Table 8 + sec 3.5.3): a memory space not covered by an +/// SAU region is fixed SECURE, so an uncovered 0x08.. address is promoted to +/// secure. Under the A/B layout the ACTIVE bank's non-secure pages sit at the LOW +/// non-secure alias (0x0802_8000..), which a region starting at 0x0804_0000 would +/// leave uncovered, so a secure-tagged write to a SECWM-nonsecure page is +/// Write-Ignored plus WRPERR (RM0456 Table 68). Covering the whole 0x0800_0000.. +/// 0x0807_FFFF alias tags both banks' non-secure pages NS under either SWAP_BANK +/// value. SECWM still makes a non-secure read of a secure page RAZ plus an +/// illegal event, so this does not weaken isolation. RM0456 memory map. +pub(crate) const FLASH_NS_BASE: u32 = 0x0800_0000; +/// Non-secure flash alias inclusive limit (whole 512 KB alias). RM0456 memory +/// map. pub(crate) const FLASH_NS_LIMIT: u32 = 0x0807_FFFF; /// Non-secure peripheral APB/AHB alias base. RM0456 memory map. @@ -213,24 +227,79 @@ pub(crate) fn sau_table() -> Result<[SauRegion; SAU_PROGRAMMED_REGIONS], Partiti } // =========================================================================== -// Secure MPU region table (PMSAv8, banked secure bank). Four regions over the -// addresses the CPU ACTUALLY emits in the secure state: secure FLASH at -// 0x0C00_0000, secure SRAM at 0x2000_0000 (NOT the 0x0E / 0x0C SRAM alias), the -// pinned non-secure shared output window near the top of the NS SRAM half, and -// the secure peripheral aliases at 0x5xxx_xxxx. These match crates/secure/ -// memory.x and crates/nonsecure/memory.x. RM0456 memory map. +// Secure MPU region table (PMSAv8, banked secure bank). Layout L1 (b2): SEVEN +// regions over the addresses the CPU ACTUALLY emits in the secure state, one +// spare of the eight implemented. RM0456 memory map, RM0456 sec 7.5.8 +// (identical-per-bank layout, the inactive bank ALWAYS at the high alias). +// +// R0 0x0C004000..0x0C027FFF RX active secure code, pages 2-19 +// R1 metadata SWAP-DERIVED RW+XN physical Bank 1 pages 0-1 (see below) +// R2 secure SRAM RW+XN +// R3 NS shared-out window RW+XN +// R4 secure peripherals RW+XN (Device) +// R5 0x0C052000..0x0C067FFF RW+XN inactive bank secure image pages 9-19 +// R6 0x08068000..0x0807FFFF RW+XN inactive bank NS image pages 20-31 // -// W^X / DEP intent: code is RX read-only (XN = 0), data, the shared output -// window, and peripherals are RW execute-never (XN = 1). With PRIVDEFENA = 0 -// there is no background map, so any secure access outside these four regions -// faults. +// Least privilege: R5/R6 start at page 9, so the MPU PHYSICALLY blocks an updater +// bug from storing into the inactive bank's metadata (pages 0-1) or its immutable +// boot stage (pages 2-8, which no region maps at the high alias). R0 excludes the +// metadata pages (0-1) so a metadata WRITE cannot land in the RX code region: the +// metadata is a separate RW region. W^X / DEP: R0 is the ONLY executable region +// (XN = 0), every writable region is execute-never (XN = 1). With PRIVDEFENA = 0 +// there is no background map, so any secure access outside these regions faults. +// R5/R6 are FIXED (the inactive bank is always at the high alias). Only R1 is +// swap-derived, re-read from OPTR.SWAP_BANK on every boot's MPU apply. // =========================================================================== -/// Secure code region base: secure FLASH Bank 1 alias. RM0456 memory map. -pub(crate) const MPU_CODE_BASE: u32 = 0x0C00_0000; -/// Secure code region inclusive limit: 256 KB (covers the NSC veneer window at -/// 0x0C03_E000). RM0456 memory map. -pub(crate) const MPU_CODE_LIMIT: u32 = 0x0C03_FFFF; +/// Secure LOW alias base: the ACTIVE bank at 0x0C00_0000. RM0456 sec 7.5.8. +const FLASH_SECURE_LOW_BASE: u32 = 0x0C00_0000; +/// Secure HIGH alias base: the INACTIVE bank at 0x0C04_0000 (512 KB U545, two +/// contiguous 256 KB banks). RM0456 sec 7.5.8, AN5347 Table 2. +const FLASH_SECURE_HIGH_BASE: u32 = 0x0C04_0000; +/// The non-secure alias sits this far below the secure alias. AN5347 Table 2. +const FLASH_SECURE_ALIAS_OFFSET: u32 = 0x0400_0000; +/// One 8 KB flash page. RM0456 sec 7.3.1 Table 51 (DUALBANK=1). +const FLASH_PAGE: u32 = 0x2000; + +/// Secure code region base: page 2 of the active bank (the immutable boot +/// stage), the first page after the metadata band. Layout L1. +pub(crate) const MPU_CODE_BASE: u32 = FLASH_SECURE_LOW_BASE + 2 * FLASH_PAGE; +/// Secure code region inclusive limit: through page 19 (the NSC veneer), pages +/// 2-19 of the active bank. Layout L1. +pub(crate) const MPU_CODE_LIMIT: u32 = FLASH_SECURE_LOW_BASE + 20 * FLASH_PAGE - 1; + +/// Boot-metadata region size: pages 0-1 (16 KB). Layout L1. +const MPU_META_SIZE: u32 = 2 * FLASH_PAGE; +/// Metadata region base when SWAP_BANK is CLEAR: physical Bank 1 at the low +/// alias. RM0456 sec 7.5.8 (the metadata is pinned to physical Bank 1). +pub(crate) const MPU_META_LOW_BASE: u32 = FLASH_SECURE_LOW_BASE; +/// Metadata region base when SWAP_BANK is SET: physical Bank 1 at the high alias. +pub(crate) const MPU_META_HIGH_BASE: u32 = FLASH_SECURE_HIGH_BASE; +/// Metadata region inclusive limit when SWAP_BANK is CLEAR (16 KB). +pub(crate) const MPU_META_LOW_LIMIT: u32 = MPU_META_LOW_BASE + MPU_META_SIZE - 1; +/// Metadata region inclusive limit when SWAP_BANK is SET (16 KB). +pub(crate) const MPU_META_HIGH_LIMIT: u32 = MPU_META_HIGH_BASE + MPU_META_SIZE - 1; + +/// Inactive-bank SECURE image region base: pages 9-19 at the secure HIGH alias. +/// The inactive bank is ALWAYS at the high alias (RM0456 sec 7.5.8), so this is +/// FIXED across swaps. Grants the updater store / read-back into the secure +/// image sub-band, never into the inactive metadata (pages 0-1) or boot stage +/// (pages 2-8), which no high-alias region maps. +pub(crate) const MPU_INACTIVE_SECURE_BASE: u32 = + FLASH_SECURE_HIGH_BASE + 9 * FLASH_PAGE; +/// Inactive-bank secure image region inclusive limit: through page 19 (88 KB). +pub(crate) const MPU_INACTIVE_SECURE_LIMIT: u32 = + FLASH_SECURE_HIGH_BASE + 20 * FLASH_PAGE - 1; + +/// Inactive-bank NON-SECURE image region base: pages 20-31 at the NS HIGH alias. +/// FIXED across swaps. The non-secure image sub-band MUST be driven through the +/// non-secure alias (0x08..), or a secure-alias access is RAZ / WRPERR (RM0456 +/// Table 68), so the updater's store / read-back of this band uses this region. +pub(crate) const MPU_INACTIVE_NS_BASE: u32 = + FLASH_SECURE_HIGH_BASE - FLASH_SECURE_ALIAS_OFFSET + 20 * FLASH_PAGE; +/// Inactive-bank NS image region inclusive limit: through page 31 (96 KB). +pub(crate) const MPU_INACTIVE_NS_LIMIT: u32 = + FLASH_SECURE_HIGH_BASE - FLASH_SECURE_ALIAS_OFFSET + 32 * FLASH_PAGE - 1; /// Secure SRAM region base: SRAM1 secure half. Reuses `SRAM1_BASE`. /// @@ -411,13 +480,55 @@ mod tests assert_eq!(MPU_SRAM_BASE, 0x2000_0000); assert_eq!(MPU_SRAM_LIMIT, SRAM1_NS_BASE - 1); assert_eq!(MPU_SRAM_LIMIT, 0x2001_FFFF); - // Code region spans the full secure FLASH bank, including the NSC window. - assert_eq!(MPU_CODE_BASE, 0x0C00_0000); - assert_eq!(MPU_CODE_LIMIT, 0x0C03_FFFF); - // The NSC veneer window is contained in the secure code region. + // Layout L1: the code region is pages 2-19 of the active bank, excluding + // the metadata band (pages 0-1) so a metadata WRITE never lands in the RX + // region. It includes the NSC veneer window (page 19). + assert_eq!(MPU_CODE_BASE, 0x0C00_4000); + assert_eq!(MPU_CODE_LIMIT, 0x0C02_7FFF); let veneer_in_code = NSC_VENEER_BASE >= MPU_CODE_BASE && NSC_VENEER_LIMIT <= MPU_CODE_LIMIT; assert!(veneer_in_code, "NSC veneer must lie inside the code region"); + assert_eq!(NSC_VENEER_BASE, 0x0C02_6000); + assert_eq!(NSC_VENEER_LIMIT, 0x0C02_7FFF); + // The swap-derived metadata region is pages 0-1 (16 KB) of physical Bank + // 1, at the low alias when SWAP_BANK is clear and the high alias when set. + assert_eq!(MPU_META_LOW_BASE, 0x0C00_0000); + assert_eq!(MPU_META_LOW_LIMIT, 0x0C00_3FFF); + assert_eq!(MPU_META_HIGH_BASE, 0x0C04_0000); + assert_eq!(MPU_META_HIGH_LIMIT, 0x0C04_3FFF); + // The low-alias metadata region ends one byte below the code region base, + // so R1 (SWAP clear) and R0 are adjacent, never overlapping. + assert_eq!(MPU_META_LOW_LIMIT + 1, MPU_CODE_BASE); + // The inactive-bank secure image region is pages 9-19 (88 KB) at the + // secure high alias, fixed across swaps. + assert_eq!(MPU_INACTIVE_SECURE_BASE, 0x0C05_2000); + assert_eq!(MPU_INACTIVE_SECURE_LIMIT, 0x0C06_7FFF); + // The inactive-bank NS image region is pages 20-31 (96 KB) at the NS high + // alias, fixed across swaps. + assert_eq!(MPU_INACTIVE_NS_BASE, 0x0806_8000); + assert_eq!(MPU_INACTIVE_NS_LIMIT, 0x0807_FFFF); + // Every region base is 32-byte aligned and every limit is an inclusive + // 32-byte top (low 5 bits set), the ARMv8-M MPU granule. + for base in [ + MPU_CODE_BASE, + MPU_META_LOW_BASE, + MPU_META_HIGH_BASE, + MPU_INACTIVE_SECURE_BASE, + MPU_INACTIVE_NS_BASE, + ] + { + assert_eq!(base & 0x1F, 0, "MPU base must be 32-byte aligned"); + } + for limit in [ + MPU_CODE_LIMIT, + MPU_META_LOW_LIMIT, + MPU_META_HIGH_LIMIT, + MPU_INACTIVE_SECURE_LIMIT, + MPU_INACTIVE_NS_LIMIT, + ] + { + assert_eq!(limit & 0x1F, 0x1F, "MPU limit must be an inclusive top"); + } // Peripheral region covers the secure peripheral aliases. assert_eq!(MPU_PERIPH_BASE, 0x5000_0000); assert_eq!(MPU_PERIPH_LIMIT, 0x5FFF_FFFF); diff --git a/crates/platform/src/mpu.rs b/crates/platform/src/mpu.rs index bec1787..81b7b3c 100644 --- a/crates/platform/src/mpu.rs +++ b/crates/platform/src/mpu.rs @@ -7,20 +7,27 @@ //! seam so the sequence is 100% host-testable. //! //! No background map: `MPU_CTRL.PRIVDEFENA` stays 0, so every secure access must -//! match one of the four enabled regions or fault (strict least privilege). The -//! SCS region 0xE000_E000-0xE000_EFFF is always Device + XN accessible regardless -//! of the MPU, so the SAU / MPU / SCB registers need no region (PM0264 line 13199). +//! match one of the enabled regions or fault (strict least privilege). The SCS +//! region 0xE000_E000-0xE000_EFFF is always Device + XN accessible regardless of +//! the MPU, so the SAU / MPU / SCB registers need no region (PM0264 line 13199). //! -//! This is RUNTIME configuration only. It touches NO irreversible / lifecycle bit -//! (no TZEN / RDP / BOOT_LOCK / WRP / option byte / OBL_LAUNCH). +//! Layout L1 (b2): seven regions of the eight implemented. R0 is the active +//! secure code (RX, the only executable region). R1 is the boot metadata (RW+XN), +//! swap-derived: it points at physical Bank 1 wherever SWAP_BANK maps it, so it is +//! re-read from `OPTR.SWAP_BANK` on every boot's apply and passed in as +//! `swap_bank`. R5/R6 grant the updater store / read-back into the INACTIVE bank's +//! image sub-bands (secure via 0x0C.., non-secure via 0x08.., RM0456 Table 68), +//! fixed at the high alias. They start at page 9, so the inactive metadata (pages +//! 0-1) and immutable boot stage (pages 2-8) are unmapped and cannot be written. use crate::bus::RegisterBus; use crate::error::PartitionError; use crate::map; use crate::regs; -/// The number of secure MPU regions programmed (of the 8 implemented). -pub(crate) const MPU_PROGRAMMED_REGIONS: usize = 4; +/// The number of secure MPU regions programmed (of the 8 implemented). Layout +/// L1 (b2) uses 7, leaving 1 spare. +pub(crate) const MPU_PROGRAMMED_REGIONS: usize = 7; /// Data-access permission for a region (`MPU_RBAR.AP`). /// @@ -51,7 +58,7 @@ impl Access /// /// Used by the W^X invariant test to prove no region is both writable and /// executable. - #[allow(dead_code)] + #[cfg(test)] const fn is_writable(self) -> bool { matches!(self, Access::ReadWritePriv) @@ -110,7 +117,8 @@ impl MpuRegion /// (low 5 bits set) or `limit` is not an inclusive 32-byte top (low 5 bits /// clear). /// - `PartitionError::MpuRegionInverted` if `limit < base`. - pub(crate) const fn new( + pub(crate) const fn new + ( base: u32, limit: u32, access: Access, @@ -170,24 +178,49 @@ impl MpuRegion /// Builds the validated secure MPU region table in `MPU_RNR` order. /// -/// Bases are strictly ascending, matching the RNR order: -/// - R0 secure code: RX read-only (AP RO priv, XN allow), Normal memory. -/// - R1 secure SRAM: RW execute-never (AP RW priv, XN never), Normal memory. -/// - R2 non-secure shared output window: RW execute-never (AP RW priv, XN never), -/// Normal memory. The ONE range in non-secure RAM the secure core may write. -/// XN preserves W^X (the secure core never executes from non-secure RAM), and -/// the region covers ONLY the pinned window. -/// - R3 secure peripherals: RW execute-never (AP RW priv, XN never), Device. +/// Layout L1 (b2), RNR order R0..R6 (NOT base-ascending: the ARMv8-M MPU indexes +/// regions by RNR and only forbids overlap, not disorder): +/// - R0 active secure code (pages 2-19): RX read-only (the only executable +/// region, W^X), Normal memory. +/// - R1 boot metadata (physical Bank 1 pages 0-1): RW execute-never, Normal, +/// SWAP-DERIVED from `swap_bank` (low alias when clear, high when set). +/// - R2 secure SRAM: RW execute-never, Normal. +/// - R3 non-secure shared output window: RW execute-never, Normal. The one range +/// in non-secure RAM the secure core may write. +/// - R4 secure peripherals: RW execute-never, Device. +/// - R5 inactive-bank secure image (pages 9-19, high alias): RW execute-never, +/// Normal. Grants the updater store / read-back of the secure sub-band. +/// - R6 inactive-bank NS image (pages 20-31, high NS alias): RW execute-never, +/// Normal. The NS sub-band must be driven through the NS alias (RM0456 Table +/// 68), so this region is at 0x08 +/// +/// `swap_bank` is the live `OPTR.SWAP_BANK` bit, read once at apply time. Only R1 +/// depends on it. R5/R6 are fixed (the inactive bank is always at the high alias). /// /// # Errors /// /// `PartitionError` if any constant in the table violates the MPU alignment or -/// ordering invariants. The fault surfaces before any hardware write, so a bad -/// edit fails the host tests rather than mis-programming silicon. -pub(crate) fn mpu_table() -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], PartitionError> +/// ordering invariants. +pub(crate) fn mpu_table +( + swap_bank: bool, +) -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], PartitionError> { - Ok([ - // R0: secure code, read-only executable (W^X: the only X region, never W). + // R1 tracks physical Bank 1: the low alias when SWAP_BANK is clear, the high + // alias when set (RM0456 sec 7.5.8). + let (meta_base, meta_limit) = if swap_bank + { + (map::MPU_META_HIGH_BASE, map::MPU_META_HIGH_LIMIT) + } + else + { + (map::MPU_META_LOW_BASE, map::MPU_META_LOW_LIMIT) + }; + Ok + ([ + // R0: active secure code, read-only executable (W^X: the only X region, + // never W). Excludes the metadata band so a metadata WRITE cannot land in + // an executable region. MpuRegion::new( map::MPU_CODE_BASE, map::MPU_CODE_LIMIT, @@ -195,7 +228,15 @@ pub(crate) fn mpu_table() -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], Partiti Exec::Allow, regs::MPU_ATTRINDX_NORMAL, )?, - // R1: secure SRAM, read-write execute-never (W^X: writable, never X). + // R1: boot metadata, read-write execute-never, swap-derived. + MpuRegion::new( + meta_base, + meta_limit, + Access::ReadWritePriv, + Exec::Never, + regs::MPU_ATTRINDX_NORMAL, + )?, + // R2: secure SRAM, read-write execute-never (W^X: writable, never X). MpuRegion::new( map::MPU_SRAM_BASE, map::MPU_SRAM_LIMIT, @@ -203,9 +244,8 @@ pub(crate) fn mpu_table() -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], Partiti Exec::Never, regs::MPU_ATTRINDX_NORMAL, )?, - // R2: pinned non-secure shared output window, read-write execute-never - // (W^X: writable, never X). Grants the secure core permission to write - // this NS range. + // R3: pinned non-secure shared output window, read-write execute-never. + // Grants the secure core permission to write this NS range. MpuRegion::new( map::MPU_NS_SHARED_BASE, map::MPU_NS_SHARED_LIMIT, @@ -213,7 +253,7 @@ pub(crate) fn mpu_table() -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], Partiti Exec::Never, regs::MPU_ATTRINDX_NORMAL, )?, - // R3: secure peripherals, read-write execute-never device memory. + // R4: secure peripherals, read-write execute-never device memory. MpuRegion::new( map::MPU_PERIPH_BASE, map::MPU_PERIPH_LIMIT, @@ -221,12 +261,28 @@ pub(crate) fn mpu_table() -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], Partiti Exec::Never, regs::MPU_ATTRINDX_DEVICE, )?, + // R5: inactive-bank secure image (pages 9-19, high alias), RW+XN. + MpuRegion::new( + map::MPU_INACTIVE_SECURE_BASE, + map::MPU_INACTIVE_SECURE_LIMIT, + Access::ReadWritePriv, + Exec::Never, + regs::MPU_ATTRINDX_NORMAL, + )?, + // R6: inactive-bank NS image (pages 20-31, high NS alias), RW+XN. + MpuRegion::new( + map::MPU_INACTIVE_NS_BASE, + map::MPU_INACTIVE_NS_LIMIT, + Access::ReadWritePriv, + Exec::Never, + regs::MPU_ATTRINDX_NORMAL, + )?, ]) } /// Programs and enables the secure MPU in the one safe order. /// -/// FAIL-CLOSED: the region table is built and validated FIRST, before any write, +/// FAIL-CLOSED: the region table is built and validated first, before any write, /// so a malformed table aborts with no half-applied MPU state. Then: /// 1. `MPU_CTRL = 0` to disable the MPU while programming, /// 2. `MPU_MAIR0` with the Normal / Device attribute bytes, @@ -234,21 +290,17 @@ pub(crate) fn mpu_table() -> Result<[MpuRegion; MPU_PROGRAMMED_REGIONS], Partiti /// 4. `MPU_CTRL = ENABLE | HFNMIENA` (PRIVDEFENA stays 0: no background map). /// /// The caller MUST issue `DSB` then `ISB` after this returns so the new MPU -/// configuration takes effect before any dependent access. Those barriers are CPU -/// intrinsics with no register-bus form, so they live in the secure binary glue. +/// configuration takes effect before any dependent access. /// /// # Errors /// /// `PartitionError` only from building the region table, surfaced before any /// hardware write. Every other step is an infallible register write. -pub fn apply_secure_mpu(bus: &mut B) -> Result<(), PartitionError> +pub fn apply_secure_mpu(bus: &mut B, swap_bank: bool) -> Result<(), PartitionError> where B: RegisterBus, { - // FAIL-CLOSED: validate the whole table before touching the MPU. A bad - // constant aborts here with the MPU still disabled (its reset state), never - // half-programmed. - let table = mpu_table()?; + let table = mpu_table(swap_bank)?; // Disable the MPU while reprogramming. bus.write32(regs::MPU_CTRL, 0); @@ -275,19 +327,20 @@ mod tests use super::*; use crate::bus::RecordingBus; - /// Runs the sequence and returns the recording bus for inspection. + /// Runs the sequence with SWAP_BANK clear and returns the recording bus. fn run() -> RecordingBus { let mut bus = RecordingBus::new(); - apply_secure_mpu(&mut bus).expect("secure MPU must apply"); + apply_secure_mpu(&mut bus, false).expect("secure MPU must apply"); bus } #[test] fn table_builds_and_validates() { - let t = mpu_table().expect("table must validate"); + let t = mpu_table(false).expect("table must validate"); assert_eq!(t.len(), MPU_PROGRAMMED_REGIONS); + assert_eq!(MPU_PROGRAMMED_REGIONS, 7, "layout L1 (b2) programs 7 regions"); } #[test] @@ -374,7 +427,7 @@ mod tests #[test] fn region_rbar_rlar_encode_periph_region() { - // R2 secure peripherals: AP RW priv (0b00), XN never (1), Device index 1. + // R4 secure peripherals: AP RW priv (0b00), XN never (1), Device index 1. let r = MpuRegion::new( 0x5000_0000, 0x5FFF_FFFF, @@ -458,7 +511,7 @@ mod tests #[test] fn region_rbar_rlar_encode_ns_shared_region() { - // R2 non-secure shared window: AP RW priv (0b00), XN never (1), SH 00, + // R3 non-secure shared window: AP RW priv (0b00), XN never (1), SH 00, // Normal index, base 0x2002_FC00, inclusive limit 0x2002_FFFF. let r = MpuRegion::new( 0x2002_FC00, @@ -476,46 +529,101 @@ mod tests #[test] fn exact_ordered_write_trace() { - // The full trace is the contract: CTRL=0, MAIR0, then per region - // RNR/RBAR/RLAR in order, then CTRL=ENABLE|HFNMIENA last. + // The full trace is the contract (SWAP_BANK clear): CTRL=0, MAIR0, then + // per region RNR/RBAR/RLAR in RNR order R0..R6, then CTRL=ENABLE|HFNMIENA + // last. RBAR low bits: RO+Allow = 0x4, RW+XN = 0x1. RLAR: LIMIT[31:5] | + // AttrIndx | EN. let bus = run(); + let device_rlar = |limit_top: u32| { + limit_top + | (regs::MPU_ATTRINDX_DEVICE << regs::MPU_RLAR_ATTRINDX_SHIFT) + | regs::MPU_RLAR_EN + }; let expected: alloc::vec::Vec<(u32, u32)> = alloc::vec![ (regs::MPU_CTRL, 0), (regs::MPU_MAIR0, 0x0000_00AA), + // R0 active secure code, RX. (regs::MPU_RNR, 0), - (regs::MPU_RBAR, 0x0C00_0004), - (regs::MPU_RLAR, 0x0C03_FFE0 | regs::MPU_RLAR_EN), + (regs::MPU_RBAR, 0x0C00_4004), + (regs::MPU_RLAR, 0x0C02_7FE0 | regs::MPU_RLAR_EN), + // R1 boot metadata (low alias, swap clear), RW+XN. (regs::MPU_RNR, 1), + (regs::MPU_RBAR, 0x0C00_0001), + (regs::MPU_RLAR, 0x0C00_3FE0 | regs::MPU_RLAR_EN), + // R2 secure SRAM, RW+XN. + (regs::MPU_RNR, 2), (regs::MPU_RBAR, 0x2000_0001), (regs::MPU_RLAR, 0x2001_FFE0 | regs::MPU_RLAR_EN), - (regs::MPU_RNR, 2), + // R3 NS shared output window, RW+XN. + (regs::MPU_RNR, 3), (regs::MPU_RBAR, 0x2002_FC01), (regs::MPU_RLAR, 0x2002_FFE0 | regs::MPU_RLAR_EN), - (regs::MPU_RNR, 3), + // R4 secure peripherals, RW+XN Device. + (regs::MPU_RNR, 4), (regs::MPU_RBAR, 0x5000_0001), - ( - regs::MPU_RLAR, - 0x5FFF_FFE0 - | (regs::MPU_ATTRINDX_DEVICE << regs::MPU_RLAR_ATTRINDX_SHIFT) - | regs::MPU_RLAR_EN - ), + (regs::MPU_RLAR, device_rlar(0x5FFF_FFE0)), + // R5 inactive-bank secure image (high alias), RW+XN. + (regs::MPU_RNR, 5), + (regs::MPU_RBAR, 0x0C05_2001), + (regs::MPU_RLAR, 0x0C06_7FE0 | regs::MPU_RLAR_EN), + // R6 inactive-bank NS image (high NS alias), RW+XN. + (regs::MPU_RNR, 6), + (regs::MPU_RBAR, 0x0806_8001), + (regs::MPU_RLAR, 0x0807_FFE0 | regs::MPU_RLAR_EN), (regs::MPU_CTRL, regs::MPU_CTRL_ENABLE | regs::MPU_CTRL_HFNMIENA), ]; assert_eq!(bus.writes(), expected.as_slice()); } #[test] - fn region_bases_strictly_ascending_and_shared_disjoint() + fn regions_never_overlap_under_either_swap() + { + // The ARMv8-M MPU indexes regions by RNR and forbids OVERLAP, not disorder + // (RNR order need not ascend). Prove no two regions overlap, for BOTH + // SWAP_BANK values, since R1 moves with the swap. A wrong R1 base that + // collided with R0 or R5 would be caught here. + for swap in [false, true] + { + let t = mpu_table(swap).expect("table"); + for i in 0..t.len() + { + for j in (i + 1)..t.len() + { + let a = t[i]; + let b = t[j]; + let disjoint = a.limit < b.base || b.limit < a.base; + assert!( + disjoint, + "regions {i} and {j} overlap under swap {swap}" + ); + } + } + } + } + + #[test] + fn r1_metadata_is_swap_derived_and_image_regions_are_fixed() { - // RNR order must follow strictly ascending region bases, and the shared - // non-secure window must not overlap the secure SRAM region. - let t = mpu_table().expect("table"); - for pair in t.windows(2) + // R1 tracks physical Bank 1: low alias when SWAP_BANK is clear, high alias + // when set. R5/R6 (the inactive image) are FIXED at the high alias under + // either swap. This guards the silicon-only fault where a metadata write + // after a swap would land outside the MPU region and HardFault. + let clear = mpu_table(false).expect("table"); + let set = mpu_table(true).expect("table"); + assert_eq!(clear[1].base, map::MPU_META_LOW_BASE, "R1 low when swap clear"); + assert_eq!(clear[1].limit, map::MPU_META_LOW_LIMIT); + assert_eq!(set[1].base, map::MPU_META_HIGH_BASE, "R1 high when swap set"); + assert_eq!(set[1].limit, map::MPU_META_HIGH_LIMIT); + // R5 (inactive secure image) and R6 (inactive NS image) do not move. + assert_eq!(clear[5].base, set[5].base, "R5 fixed across swap"); + assert_eq!(clear[5].base, map::MPU_INACTIVE_SECURE_BASE); + assert_eq!(clear[6].base, set[6].base, "R6 fixed across swap"); + assert_eq!(clear[6].base, map::MPU_INACTIVE_NS_BASE); + // Every other region is identical across the two tables. + for i in [0usize, 2, 3, 4, 5, 6] { - assert!(pair[0].base < pair[1].base, "region bases must ascend"); + assert_eq!(clear[i], set[i], "region {i} must not depend on swap"); } - // R1 secure SRAM ends strictly below R2 shared-window base. - assert!(t[1].limit < t[2].base, "shared window must not overlap secure SRAM"); } #[test] @@ -523,11 +631,11 @@ mod tests { // W^X: the code region is RO + executable, the data and peripheral regions // are writable + execute-never. No region is both writable and executable. - let t = mpu_table().expect("table"); + let t = mpu_table(false).expect("table"); // R0 code: not writable, executable. assert!(!t[0].access.is_writable(), "code region must not be writable"); assert_eq!(t[0].exec, Exec::Allow, "code region must be executable"); - // R1 SRAM + R2 periph: writable, execute-never. + // R1..R6 every non-code region: writable, execute-never. for region in &t[1..] { assert!(region.access.is_writable(), "data region must be writable"); diff --git a/crates/secure/build.rs b/crates/secure/build.rs index 3b167fd..c9d470c 100644 --- a/crates/secure/build.rs +++ b/crates/secure/build.rs @@ -38,10 +38,12 @@ use std::path::PathBuf; const TARGET_TRIPLE: &str = "thumbv8m.main-none-eabihf"; /// The stable file name of the CMSE import object under the target triple dir. const IMPLIB_FILE: &str = "patinakey_nsc_implib.o"; -/// The pinned NSC veneer window base: top 8 KB of secure Bank 1. The CMSE -/// secure-gateway veneers (.gnu.sgstubs) are forced here so the SAU-marked NSC -/// address is stable across builds. RM0456 memory map matches platform map.rs. -const NSC_VENEER_BASE: &str = "0x0C03E000"; +/// The pinned NSC veneer window base: page 19, the top page of the secure app +/// band (pages 10-19). The CMSE secure-gateway veneers (.gnu.sgstubs) are forced +/// here so the SAU-marked NSC address is stable across builds. It sits inside +/// the secure FLASH region [0x0C014000, 0x0C028000). RM0456 memory map matches +/// platform map.rs. +const NSC_VENEER_BASE: &str = "0x0C026000"; /// Derives the cargo target-root directory from `OUT_DIR`. /// diff --git a/crates/secure/memory.x b/crates/secure/memory.x index ab3106f..d35e32d 100644 --- a/crates/secure/memory.x +++ b/crates/secure/memory.x @@ -1,23 +1,34 @@ /* Secure-world (TZ-S) memory layout for cortex-m-rt's link.x. * - * FLASH is secure Bank 1 at the secure alias 0x0C00_0000, the FULL 256 KB bank. - * The TOP 8 KB (0x0C03_E000) is the Non-Secure-Callable veneer window: the build - * pins the CMSE secure-gateway veneers (.gnu.sgstubs) to 0x0C03_E000 with a - * linker --section-start (see build.rs), so they land at the fixed address the - * SAU marks Non-Secure-Callable. Ordinary secure code/data uses the lower 248 KB. - * The single FLASH region (not a separate carve-out) is deliberate: cortex-m-rt's - * link.x assigns .gnu.sgstubs to FLASH, so a second region would leave that - * section's region unbound. The --section-start instead pins the address inside - * the one FLASH region. RM0456 memory map (Bank 1 secure alias). + * FLASH is the secure app band, pages 10-19 of a 256 KB bank, at the low secure + * alias 0x0C01_4000, LENGTH 80 KB (10 pages x 8 KB). Page 9 (0x0C01_2000) is the + * A/B image DESCRIPTOR (the signed header and signature), NOT secure app: the + * updater de-interleaves the signed file so the payload lands page-aligned here + * at the link origin and the header magic never lands on the secure app vector + * table. The immutable boot stage (pages 2-8), the boot metadata (pages 0-1), and + * the descriptor (page 9) all sit BELOW this origin and are never linked into the + * secure app. The active bank always presents this band at the low alias, the + * inactive bank presents it at the high alias 0x0C05_4000, so SAU, SECWM and the + * MPU never change on a bank swap. * - * RAM is the lower 128 KB of SRAM1 at 0x2000_0000 (the provisional secure RAM - * half), matching the SAU region 2 / MPCBB1 split declared in platform's map.rs. + * The Non-Secure-Callable veneer window is page 19 at 0x0C02_6000 (the top page + * of this band). The build pins the CMSE secure-gateway veneers (.gnu.sgstubs) + * there with a linker --section-start (see build.rs), so they land at the fixed + * address the SAU marks Non-Secure-Callable. Ordinary secure code/data uses + * pages 10-18 (72 KB) below the veneer. The single FLASH region (not a separate + * carve-out) is deliberate: cortex-m-rt's link.x assigns .gnu.sgstubs to FLASH, + * so a second region would leave that section's region unbound. The + * --section-start instead pins the address inside the one FLASH region. RM0456 + * memory map (Bank secure alias, sec 7.5.8 identical-per-bank layout). + * + * RAM is the lower 128 KB of SRAM1 at 0x2000_0000 (the secure RAM half), + * matching the SAU region 2 / MPCBB1 split declared in platform's map.rs. * * The standard cortex-m-rt FLASH/RAM region names are used so link.x composes * without edits. */ MEMORY { - FLASH (rx) : ORIGIN = 0x0C000000, LENGTH = 256K + FLASH (rx) : ORIGIN = 0x0C014000, LENGTH = 80K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } diff --git a/crates/secure/src/main.rs b/crates/secure/src/main.rs index 80e7a9e..a181b6f 100644 --- a/crates/secure/src/main.rs +++ b/crates/secure/src/main.rs @@ -68,9 +68,19 @@ mod firmware use platform::apply_secure_mpu; use platform::MmioBus; - /// Non-secure vector table base: NS flash Bank 2 (NS alias). RM0456 memory - /// map. word[0] = NS initial MSP, word[1] = NS reset entry. - const NS_VECTOR_TABLE: u32 = 0x0804_0000; + /// Non-secure vector table base: the non-secure app band (pages 20-31) at the + /// LOW non-secure alias. Layout L1 places the NS app at 0x0802_8000 (matching + /// crates/nonsecure/memory.x FLASH ORIGIN), not the old whole-bank NS base. + /// RM0456 memory map. word[0] = NS initial MSP, word[1] = NS reset entry. + const NS_VECTOR_TABLE: u32 = 0x0802_8000; + + /// `FLASH_OPTR` (option register, secure alias) holding SWAP_BANK. RM0456 sec + /// 7.9.35 Table 79 (FLASH secure base 0x5002_2000, OPTR offset 0x40). + const FLASH_OPTR: u32 = 0x5002_2040; + + /// `OPTR.SWAP_BANK` bit 20. RM0456 sec 7.9.13. Set when physical Bank 2 boots + /// low, which moves physical Bank 1 (the metadata) to the high alias. + const OPTR_SWAP_BANK: u32 = 1 << 20; /// `SCB_NS->VTOR`: the non-secure alias of the SCB VTOR register. The NS SCB /// is the standard SCB block at the non-secure-alias base, VTOR at +0xD08 @@ -164,10 +174,25 @@ mod firmware (ns_msp, ns_reset) }; + // Read the live OPTR.SWAP_BANK so the boot-metadata MPU region (R1) tracks + // physical Bank 1's CURRENT alias: low when clear, high when set (RM0456 + // sec 7.5.8 / 7.9.13). After an A/B swap the newly-booted firmware re-runs + // this with the flipped bit, so a post-swap metadata write stays inside its + // region instead of HardFaulting. + // + // SAFETY: FLASH_OPTR is a valid 32-bit-aligned MMIO register on the + // STM32U545, read volatile so the read is not elided or reordered. The MPU + // is still OFF here, so the secure-peripheral access does not fault. No + // value crosses as a pointer to secure memory. + let swap_bank = unsafe + { + ptr::read_volatile(FLASH_OPTR as *const u32) & OPTR_SWAP_BANK != 0 + }; + // Enable the secure MPU as the LAST isolation step. On error FAIL-CLOSED: // wedge and never hand off, so the NS world cannot start with the secure // world unprotected. - if apply_secure_mpu(bus).is_err() + if apply_secure_mpu(bus, swap_bank).is_err() { loop { diff --git a/crates/tropic01-driver/scripts/install-model.sh b/crates/tropic01-driver/scripts/install-model.sh new file mode 100755 index 0000000..d51e888 --- /dev/null +++ b/crates/tropic01-driver/scripts/install-model.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Installs the pinned official TROPIC01 model (ts-tvl) for the live integration +# suite. +# +# Usage: +# export LIBTROPIC=/path/to/libtropic +# crates/tropic01-driver/scripts/install-model.sh + +set -euo pipefail + +# Pinned model version. +TVL_VERSION="2.5" +TVL_SHA256="c51c5dd35a6e075d9dd71c17abba53f4669d54cb4b96500295ee279a9c192ed2" +TVL_WHEEL="tvl-${TVL_VERSION}-py3-none-any.whl" +TVL_URL="https://github.com/tropicsquare/ts-tvl/releases/download/${TVL_VERSION}/${TVL_WHEEL}" + +LT="${LIBTROPIC:?set LIBTROPIC to your libtropic checkout (the model lives at scripts/tropic01_model)}" +MODEL_DIR="$LT/scripts/tropic01_model" +VENV_DIR="$MODEL_DIR/.venv" + +if [[ ! -f "$MODEL_DIR/model_cfg.yml" ]]; then + echo "model config not found at $MODEL_DIR/model_cfg.yml" >&2 + echo "LIBTROPIC must point at a libtropic checkout." >&2 + exit 1 +fi + +for tool in sha256sum python3; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "missing required tool: $tool" >&2 + exit 1 + fi +done + +if command -v curl >/dev/null 2>&1; then + download() { curl -fsSL "$1" -o "$2"; } +elif command -v wget >/dev/null 2>&1; then + download() { wget -q -O "$2" "$1"; } +else + echo "missing required tool: curl or wget" >&2 + exit 1 +fi + +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + echo "Creating virtual environment at $VENV_DIR..." + python3 -m venv "$VENV_DIR" +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { rm -rf "$TMP_DIR"; } +trap cleanup EXIT + +echo "Downloading $TVL_WHEEL..." +download "$TVL_URL" "$TMP_DIR/$TVL_WHEEL" + +actual="$(sha256sum "$TMP_DIR/$TVL_WHEEL" | awk '{print $1}')" +if [[ "$actual" != "$TVL_SHA256" ]]; then + echo "wheel checksum mismatch: expected $TVL_SHA256, got $actual" >&2 + exit 1 +fi + +echo "Installing ts-tvl $TVL_VERSION..." +"$VENV_DIR/bin/python" -m pip install --quiet --upgrade pip +"$VENV_DIR/bin/python" -m pip install --quiet "$TMP_DIR/$TVL_WHEEL" + +installed="$("$VENV_DIR/bin/python" -c 'import importlib.metadata as m; print(m.version("tvl"))')" +if [[ "$installed" != "$TVL_VERSION" ]]; then + echo "installed ts-tvl is $installed, expected $TVL_VERSION" >&2 + exit 1 +fi + +echo "TROPIC01 model ts-tvl $TVL_VERSION installed at $VENV_DIR" diff --git a/crates/tropic01-driver/scripts/model-itest.sh b/crates/tropic01-driver/scripts/model-itest.sh index e42245d..7c11e70 100755 --- a/crates/tropic01-driver/scripts/model-itest.sh +++ b/crates/tropic01-driver/scripts/model-itest.sh @@ -8,11 +8,11 @@ # # HOST TEST ONLY. Validates protocol byte-exactness, not physical security. # -# Prerequisite (one-time): install the model into a venv with the official -# installer, then point LIBTROPIC at your libtropic checkout: +# Prerequisite (one-time): point LIBTROPIC at your libtropic checkout, then +# install the pinned model wheel into its venv: # -# "$LIBTROPIC/scripts/tropic01_model/install_linux.sh" # export LIBTROPIC=/path/to/libtropic +# crates/tropic01-driver/scripts/install-model.sh # crates/tropic01-driver/scripts/model-itest.sh # # The model server pins the chip secrets from model_cfg.yml (chip static key + @@ -22,21 +22,32 @@ set -euo pipefail LT="${LIBTROPIC:?set LIBTROPIC to your libtropic checkout (the model lives at scripts/tropic01_model)}" MODEL_DIR="$LT/scripts/tropic01_model" -MODEL_SERVER="$MODEL_DIR/.venv/bin/model_server" +MODEL_VENV="$MODEL_DIR/.venv" +MODEL_SERVER="$MODEL_VENV/bin/model_server" MODEL_CFG="$MODEL_DIR/model_cfg.yml" MODEL_HOST="127.0.0.1" MODEL_PORT="28992" +# Resolve the driver crate root. This script lives in +# crates/tropic01-driver/scripts, so the crate manifest is one level up. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CRATE_DIR="$(dirname "$SCRIPT_DIR")" +INSTALLER="$SCRIPT_DIR/install-model.sh" + if [[ ! -x "$MODEL_SERVER" ]]; then echo "model_server not found at $MODEL_SERVER" >&2 - echo "Run $MODEL_DIR/install_linux.sh first." >&2 + echo "Run $INSTALLER first." >&2 exit 1 fi -# Resolve the firmware crate root (this script lives in firmware/scripts). -# This script lives in crates/tropic01-driver/scripts; the crate manifest is one level up. -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CRATE_DIR="$(dirname "$SCRIPT_DIR")" +# The version pin lives in install-model.sh, this only enforces it. +WANT_VERSION="$(sed -n 's/^TVL_VERSION="\(.*\)"$/\1/p' "$INSTALLER")" +HAVE_VERSION="$("$MODEL_VENV/bin/python" -c 'import importlib.metadata as m; print(m.version("tvl"))')" +if [[ "$WANT_VERSION" != "$HAVE_VERSION" ]]; then + echo "model venv has ts-tvl $HAVE_VERSION, this repository pins $WANT_VERSION" >&2 + echo "Run $INSTALLER to update it." >&2 + exit 1 +fi SERVER_PID="" cleanup() { diff --git a/crates/tropic01-driver/tests/oracle/README.md b/crates/tropic01-driver/tests/oracle/README.md index 3f04d00..3f8fe2a 100644 --- a/crates/tropic01-driver/tests/oracle/README.md +++ b/crates/tropic01-driver/tests/oracle/README.md @@ -74,9 +74,9 @@ byte chunks) plus a 16-byte Random_Get (one 20-byte chunk). tag). It is an external, read-only reference and is NOT part of this repository. ``` -# 1. Install the model (downloads the ts-tvl wheel into a venv). +# 1. Install the model (downloads the pinned ts-tvl wheel into a venv). LT=/path/to/libtropic -"$LT/scripts/tropic01_model/install_linux.sh" +LIBTROPIC="$LT" crates/tropic01-driver/scripts/install-model.sh # 2. Drop l2_frame_capture.c into a model example and build with SPI printing. mkdir -p "$LT/examples/model/kat_capture" diff --git a/docs/ab-bench.md b/docs/ab-bench.md new file mode 100644 index 0000000..9d9f1e4 --- /dev/null +++ b/docs/ab-bench.md @@ -0,0 +1,54 @@ +# `scripts/ab-bench.sh` + +Builds, signs, flashes and observes the A/B firmware (single signed 256 KB bank) over +ST-LINK. `probe-rs run` cannot drive an A/B image, it bypasses the boot stage. This +runner only ever writes bank content, never an option byte. + +## Prerequisites + +- `probe-rs` + STM32CubeProgrammer CLI on `PATH`, ST-LINK wired to SWD. +- `thumbv8m.main-none-eabihf` target installed. +- External ECDSA P-256 signer (YubiKey PIV by default, or `SIG=` for a signature file). +- Part already provisioned (`SECWM` pages 0-19, `SECBOOTADD0`). Checked by `preflight`. + +## Commands + +```sh +scripts/ab-bench.sh preflight # option-byte check, read-only, aborts if unprovisioned +scripts/ab-bench.sh build # secure -> nonsecure -> boot +scripts/ab-bench.sh sign # prepare-external + sign + finalize -> bank.bin +scripts/ab-bench.sh flash # split bank.bin + two-alias flash + read back +scripts/ab-bench.sh attach # defmt decoder only, no flash, no reset +scripts/ab-bench.sh all # everything (default) +``` + +The flash is split at offset `0x28000`: secure `0..0x28000` -> `0x0C000000`, +non-secure `0x28000..0x40000` -> `0x08028000`. To restore a known good image, run +`flash` alone — it reuses the existing `bank.bin`, no rebuild, no new signature. + +## Variables + +| Variable | Default | Effect | +|----------|---------|--------| +| `CUBE_CLI` | `~/Documents/applications/STM32CubeProgrammer/bin/STM32_Programmer_CLI` | CLI path | +| `CHIP` | `STM32U545CEUx` | `probe-rs` chip name | +| `PKCS11_MODULE` | `/usr/lib/libykcs11.so` | PKCS#11 module | +| `KEY_ID` | `05` | PIV slot 82 | +| `SIG` | — | Pre-made signature, skips the `pkcs11-tool` call | +| `YES` | `0` | `1` skips the pre-flash confirmation | +| `DEFMT_LOG` | `info` | defmt filter, baked at build time | +| `V_MAJOR` `V_MINOR` `V_REVISION` `V_BUILD` `V_SECCOUNT` | `0 0 1 1 0` | Version + anti-rollback counter | + +```sh +SIG=/path/to/sig.raw YES=1 scripts/ab-bench.sh all +``` + +## Notes + +- Signature is RAW ECDSA P-256 over the 32 digest bytes — the card must not re-hash. + `finalize-external` normalizes to low-s. +- DWARF warning / `` tags are cosmetic; build with `debug = 2` for + source locations. +- The RTT buffer survives reset (`SRAM_RST`), so stale or garbled frames can show up + after a fresh flash. Power cycle for a clean stream. Never judge acceptance from the + attach output, read the core state over SWD. \ No newline at end of file diff --git a/scripts/ab-bench.sh b/scripts/ab-bench.sh new file mode 100755 index 0000000..0fdd47e --- /dev/null +++ b/scripts/ab-bench.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# +# PatinaKey A/B bring-up runner (build -> sign -> two-alias flash -> attach). +# +# The A/B image is a single 256 KB bank made of four de-interleaved segments +# (boot metadata, immutable boot-stage, signed descriptor, secure app, NS app). +# It boots through SECBOOTADD0 -> boot-stage -> signature verify -> secure -> +# bxns -> NS. Unlike the flat two-ELF build, you cannot flash it with a single +# probe-rs run, so this wrapper assembles, signs, and flashes it end to end. +# +# Sub-commands: +# ab-bench.sh preflight READ-ONLY. Confirm probe + that the part is already +# provisioned (SECWM + SECBOOTADD0). Writes nothing. +# ab-bench.sh build Build the three ELF images (secure, nonsecure, boot). +# ab-bench.sh sign build + prepare-external + YubiKey sign + finalize +# -> produces bank.bin (verified against the pinned key). +# ab-bench.sh flash Split bank.bin and flash the two aliases + read back. +# Requires a bank.bin from a prior sign. +# ab-bench.sh attach Just attach probe-rs. +# ab-bench.sh all (default) preflight + build + sign + flash + attach. +# +# BRICK-SAFETY: this script flashes ONLY reflashable bank content +# (pages 0-31 of the active bank, both aliases). It never writes an option byte, +# never sets or clears TZEN, SECWM, SECBOOTADD0, RDP, BOOT_LOCK, WRP, HDP or any +# OEM key. Every write here is reversible by another flash. Option-byte +# provisioning is a separate manual step and is intentionally absent. +# The preflight aborts if the part is not already provisioned, because flashing +# the A/B layout onto an un-provisioned part gives a (recoverable) dead boot. +# +# The YubiKey signing step needs a physical touch + PIN. The private key never +# leaves the card. If you prefer to sign by hand, pass SIG=/path/to/sig.raw to +# skip the pkcs11-tool call. + +set -euo pipefail + +# Resolve paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +TARGET="thumbv8m.main-none-eabihf" +OUT_DIR="target/${TARGET}/release" +BOOT_ELF="${REPO_ROOT}/${OUT_DIR}/boot-stage" +SECURE_ELF="${REPO_ROOT}/${OUT_DIR}/secure" +NONSECURE_ELF="${REPO_ROOT}/${OUT_DIR}/nonsecure" + +SIGNER_MANIFEST="${REPO_ROOT}/tools/image-signer/Cargo.toml" +PUBKEY="${REPO_ROOT}/crates/boot-stage/product_root_key.sec1" + +# Work directory for all artifacts, inside the already-ignored target tree. +WORK="${REPO_ROOT}/target/ab-bench" +DIGEST="${WORK}/digest.bin" +DIGEST_HEX="${WORK}/digest.hex" +CONTEXT="${WORK}/context.bin" +SIG_DEFAULT="${WORK}/sig.raw" +BANK="${WORK}/bank.bin" +MANIFEST="${WORK}/manifest.txt" +BANK_SEC="${WORK}/bank_secure.bin" +BANK_NS="${WORK}/bank_ns.bin" +RB_SEC="${WORK}/readback_secure.bin" +RB_NS="${WORK}/readback_ns.bin" + +# Geometry (must match crates/*/memory.x and image-signer bank.rs) +# Secure region = pages 0-19 at offset 0, length 0x28000, flashed via the secure +# alias. NS region = pages 20-31 at offset 0x28000, length 0x18000, flashed via +# the NS alias. The split at 0x28000 is the secure/NS page-20 boundary. +SEC_ADDR="0x0C000000" +SEC_LEN="0x28000" +NS_ADDR="0x08028000" +NS_LEN="0x18000" +SPLIT_BYTES=163840 # 0x28000 + +# Overridable knobs +CUBE_CLI="${CUBE_CLI:-${HOME}/Documents/applications/STM32CubeProgrammer/bin/STM32_Programmer_CLI}" +CHIP="${CHIP:-STM32U545CEUx}" +PKCS11_MODULE="${PKCS11_MODULE:-/usr/lib/libykcs11.so}" +KEY_ID="${KEY_ID:-05}" +SIG="${SIG:-}" # set to a path to skip the pkcs11-tool signing call +YES="${YES:-0}" # set to 1 to skip the pre-flash confirmation prompt + +# defmt log level baked at BUILD time. defmt filters at COMPILE time, so an unset +# DEFMT_LOG drops every info log to silence even though the firmware runs and the +# RTT control block still initializes (WrOff stays 0). This MUST be exported before +# the builds. Changing it forces defmt and its dependents to recompile. +export DEFMT_LOG="${DEFMT_LOG:-info}" + +# Image version fields (kept identical to the first-light values by default). +V_MAJOR="${V_MAJOR:-0}" +V_MINOR="${V_MINOR:-0}" +V_REVISION="${V_REVISION:-1}" +V_BUILD="${V_BUILD:-1}" +V_SECCOUNT="${V_SECCOUNT:-0}" + +# CubeProgrammer connect options. mode=UR + freq=1000 is the reliable link for +# every flash and read here. +CUBE_CONN=(-c port=SWD mode=UR freq=1000) + +log() { printf '>> %s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2 ; exit 1; } + +require_tool() +{ + command -v "$1" >/dev/null 2>&1 || die "missing tool: $1" +} + +# preflight: READ-ONLY provisioning + probe check +cmd_preflight() +{ + [ -x "${CUBE_CLI}" ] || die "STM32_Programmer_CLI not found at ${CUBE_CLI} (set CUBE_CLI=...)" + mkdir -p "${WORK}" + local ob="${WORK}/ob_displ.txt" + log "reading option bytes (read-only)" + "${CUBE_CLI}" "${CUBE_CONN[@]}" -ob displ | tee "${ob}" >/dev/null + + # The part MUST already carry SECBOOTADD0 -> 0x0C004000 and SECWM1/2 pages + # 0-19, or flashing the A/B layout dead-boots (recoverable, but abort here). + grep -Eq 'SECBOOTADD0[[:space:]]*:[[:space:]]*0x180080' "${ob}" \ + || die "part NOT provisioned: SECBOOTADD0 is not 0x180080. Provision the option bytes first (gated manual step). ABORTING before any flash." + grep -Eq 'SECWM1_PEND[[:space:]]*:[[:space:]]*0x13' "${ob}" \ + || die "part NOT provisioned: SECWM1_PEND is not 0x13 (pages 0-19 secure). ABORTING." + grep -Eq 'SECWM2_PEND[[:space:]]*:[[:space:]]*0x13' "${ob}" \ + || die "part NOT provisioned: SECWM2_PEND is not 0x13. ABORTING." + log "preflight OK: probe present, SECBOOTADD0 + SECWM1/2 provisioned" +} + +# build: the three ELF images, secure before nonsecure +cmd_build() +{ + require_tool cargo + # Secure links first so the CMSE import object exists for the NS link. + log "build secure" + cargo build -p secure --release --target "${TARGET}" --locked + log "build nonsecure" + cargo build -p nonsecure --release --target "${TARGET}" --locked + log "build boot-stage" + cargo build -p boot-stage --release --target "${TARGET}" --locked + [ -f "${BOOT_ELF}" ] && [ -f "${SECURE_ELF}" ] && [ -f "${NONSECURE_ELF}" ] \ + || die "one or more ELF images missing after build" +} + +# sign: prepare -> YubiKey sign -> finalize -> bank.bin +cmd_sign() +{ + require_tool cargo + mkdir -p "${WORK}" + + log "prepare-external (emit digest + context)" + cargo run --manifest-path "${SIGNER_MANIFEST}" -- prepare-external \ + --boot "${BOOT_ELF}" \ + --secure "${SECURE_ELF}" \ + --nonsecure "${NONSECURE_ELF}" \ + --major "${V_MAJOR}" --minor "${V_MINOR}" --revision "${V_REVISION}" \ + --build "${V_BUILD}" --security-counter "${V_SECCOUNT}" \ + --digest "${DIGEST}" --context "${CONTEXT}" --digest-hex "${DIGEST_HEX}" + + local sigfile="${SIG}" + if [ -z "${sigfile}" ] + then + require_tool pkcs11-tool + sigfile="${SIG_DEFAULT}" + printf '\n' + log "SIGN the digest with the YubiKey now (physical TOUCH + PIN)" + log " module=${PKCS11_MODULE} id=${KEY_ID} mechanism=ECDSA" + printf '\n' + pkcs11-tool --module "${PKCS11_MODULE}" --sign --mechanism ECDSA \ + --id "${KEY_ID}" --input-file "${DIGEST}" --output-file "${sigfile}" + else + log "using supplied signature: ${sigfile}" + fi + [ -s "${sigfile}" ] || die "signature file is empty: ${sigfile}" + + log "finalize-external (verify vs pinned key, lay out + self-verify bank)" + cargo run --manifest-path "${SIGNER_MANIFEST}" -- finalize-external \ + --context "${CONTEXT}" \ + --signature "${sigfile}" \ + --pubkey "${PUBKEY}" \ + --out "${BANK}" \ + --manifest "${MANIFEST}" + [ -f "${BANK}" ] || die "finalize produced no bank.bin" + log "bank.bin ready: ${BANK}" +} + +# flash: split + two-alias flash + read-back verify +cmd_flash() +{ + [ -x "${CUBE_CLI}" ] || die "STM32_Programmer_CLI not found at ${CUBE_CLI}" + [ -f "${BANK}" ] || die "no bank.bin at ${BANK} (run 'sign' first)" + + # Split at the secure/NS page-20 boundary. + log "split bank.bin at offset ${SEC_LEN} (secure | NS)" + dd if="${BANK}" of="${BANK_SEC}" bs="${SPLIT_BYTES}" count=1 status=none + dd if="${BANK}" of="${BANK_NS}" bs="${SPLIT_BYTES}" skip=1 status=none + + if [ "${YES}" != "1" ] + then + printf '\n' + printf 'About to FLASH (reversible bank content, NO option bytes):\n' + printf ' secure region -> %s (%s bytes) at %s\n' "${BANK_SEC}" "${SEC_LEN}" "${SEC_ADDR}" + printf ' NS region -> %s (%s bytes) at %s\n' "${BANK_NS}" "${NS_LEN}" "${NS_ADDR}" + read -r -p 'Proceed? [y/N] ' ans + case "${ans}" in + y|Y|yes|YES) ;; + *) die "aborted by user" ;; + esac + fi + + # Secure region: flash then read the same range back and compare. + log "flash secure region -> ${SEC_ADDR}" + "${CUBE_CLI}" "${CUBE_CONN[@]}" -d "${BANK_SEC}" "${SEC_ADDR}" + "${CUBE_CLI}" "${CUBE_CONN[@]}" -r "${SEC_ADDR}" "${SEC_LEN}" "${RB_SEC}" + cmp "${RB_SEC}" "${BANK_SEC}" || die "secure region read-back MISMATCH" + log "secure region read-back OK" + + # NS region via the NS alias (pages 20-31 are non-secure after SECWM). + log "flash NS region -> ${NS_ADDR}" + "${CUBE_CLI}" "${CUBE_CONN[@]}" -d "${BANK_NS}" "${NS_ADDR}" + "${CUBE_CLI}" "${CUBE_CONN[@]}" -r "${NS_ADDR}" "${NS_LEN}" "${RB_NS}" + cmp "${RB_NS}" "${BANK_NS}" || die "NS region read-back MISMATCH" + log "NS region read-back OK" + log "flash complete, both regions verified" +} + +# attach +cmd_attach() +{ + require_tool probe-rs + log "attach probe-rs" + probe-rs attach --chip "${CHIP}" "${NONSECURE_ELF}" +} + +cmd_all() +{ + cmd_preflight + cmd_build + cmd_sign + cmd_flash + cmd_attach +} + +COMMAND="${1:-all}" +case "${COMMAND}" in + preflight) cmd_preflight ;; + build) cmd_build ;; + sign) cmd_build ; cmd_sign ;; + flash) cmd_flash ;; + attach) cmd_attach ;; + all) cmd_all ;; + *) die "usage: ab-bench.sh [preflight|build|sign|flash|attach|all]" ;; +esac diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 92d3e29..81f3ea4 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -110,6 +110,16 @@ coverage_stage() if [ -n "${LIBTROPIC:-}" ] && [ -x "${LIBTROPIC}/scripts/tropic01_model/.venv/bin/model_server" ] then local model="${LIBTROPIC}/scripts/tropic01_model" + local installer="crates/tropic01-driver/scripts/install-model.sh" + local want have + want="$(sed -n 's/^TVL_VERSION="\(.*\)"$/\1/p' "$installer")" + have="$("$model/.venv/bin/python" -c 'import importlib.metadata as m; print(m.version("tvl"))')" + if [ "$want" != "$have" ] + then + echo "ERROR: model venv has ts-tvl $have, this repository pins $want" >&2 + echo "Run $installer to update it." >&2 + return 1 + fi "$model/.venv/bin/model_server" tcp -c "$model/model_cfg.yml" \ -o /tmp/ci-local-model-save.yml \ > /tmp/ci-local-model.log 2>&1 & diff --git a/tools/image-signer/Cargo.lock b/tools/image-signer/Cargo.lock index d967b7a..77e1c33 100644 --- a/tools/image-signer/Cargo.lock +++ b/tools/image-signer/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "block-buffer" version = "0.12.1" @@ -17,6 +29,24 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -26,6 +56,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -33,33 +78,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", + "rand_core", ] [[package]] -name = "curve25519-dalek" -version = "5.0.0" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", + "cmov", "subtle", - "zeroize", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "der" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "const-oid", + "zeroize", ] [[package]] @@ -69,36 +108,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", + "ctutils", ] [[package]] -name = "ed25519" -version = "3.0.0" +name = "ecdsa" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", "signature", + "zeroize", ] [[package]] -name = "ed25519-dalek" -version = "3.0.0" +name = "elliptic-curve" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "curve25519-dalek", - "ed25519", - "sha2", + "base16ct", + "crypto-bigint", + "crypto-common", + "digest", + "ff", + "group", + "hybrid-array", + "rand_core", + "sec1", "subtle", "zeroize", ] [[package]] -name = "fiat-crypto" -version = "0.3.0" +name = "ff" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core", + "subtle", +] [[package]] name = "fw-update" @@ -107,22 +163,46 @@ dependencies = [ "image-verify", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + [[package]] name = "hybrid-array" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] name = "image-signer" version = "0.0.1" dependencies = [ - "ed25519-dalek", + "ecdsa", "fw-update", "image-verify", + "p256", + "sha2", "zeroize", ] @@ -130,7 +210,8 @@ dependencies = [ name = "image-verify" version = "0.0.1" dependencies = [ - "ed25519-dalek", + "p256", + "sha2", ] [[package]] @@ -140,37 +221,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "unicode-ident", + "autocfg", ] [[package]] -name = "quote" -version = "1.0.46" +name = "p256" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "proc-macro2", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "primefield" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "semver", + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", ] [[package]] -name = "semver" -version = "1.0.28" +name = "primeorder" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "wnaf", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] [[package]] name = "sha2" @@ -188,6 +313,10 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest", + "rand_core", +] [[package]] name = "subtle" @@ -195,17 +324,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "typenum" version = "1.20.1" @@ -213,10 +331,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "wnaf" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] [[package]] name = "zeroize" diff --git a/tools/image-signer/Cargo.toml b/tools/image-signer/Cargo.toml index 29f5569..b40a1c1 100644 --- a/tools/image-signer/Cargo.toml +++ b/tools/image-signer/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.88" authors = ["PatinaKey "] license = "GPL-3.0-or-later" -description = "Host-only tool that signs patina_key firmware images with an Ed25519 key." +description = "Host-only tool that signs patina_key firmware images with an ECDSA P-256 key." publish = false # Detach from the firmware workspace: this is a HOST tool, never built for the @@ -26,26 +26,29 @@ path = "src/main.rs" # truth for the header bytes, and verify_image gives the round-trip self-check. image-verify = { path = "../../crates/image-verify", features = ["encode"] } -# Ed25519 signing from a raw 32-byte seed. The zeroize feature wipes the -# SigningKey on drop, so no plaintext key scalar lingers in memory. -ed25519-dalek = { version = "3.0", default-features = false, features = ["zeroize"] } +# ECDSA P-256 signing from a raw 32-byte private scalar. +p256 = { version = "0.14", default-features = false, features = ["ecdsa"] } -# Wipes the intermediate plaintext seed buffer on drop. +# Enable DER support for `ecdsa::Signature::from_der`. +ecdsa = { version = "0.17", default-features = false, features = ["der"] } + +# Wipes the intermediate plaintext seed buffer on drop. # The alloc feature lets a Vec seed buffer be wiped too. zeroize = { version = "1.9", default-features = false, features = ["alloc"] } +# SHA-256 for deriving the bring-up root key. +sha2 = { version = "0.11", default-features = false } + [dev-dependencies] # The integration tests verify the signed output through the real verify path, # so they read it back exactly as the device would. image-verify = { path = "../../crates/image-verify", features = ["encode"] } -# End-to-end test only: drive the tool's signed output through the real -# dual-bank update machine, which pins the dev root key. This edge runs ONLY in -# the signer's host tests. The update machine never depends on this tool, so the -# MCU target build is untouched. -# -# The `_fuzz` feature is what exposes the host MockFlash / MockSeCounter seams to -# an external test consumer (they are otherwise gated to fw-update's own tests). +# Derive the bring-up root key from the fixed test phrase instead of embedding +# the expected key bytes. +sha2 = { version = "0.11", default-features = false } + +# Expose the host test interfaces used by the end-to-end signer tests. fw-update = { path = "../../crates/fw-update", features = ["_fuzz"] } [lints.rust] diff --git a/tools/image-signer/src/bank.rs b/tools/image-signer/src/bank.rs new file mode 100644 index 0000000..e12279e --- /dev/null +++ b/tools/image-signer/src/bank.rs @@ -0,0 +1,747 @@ +//! First-light bank assembler for the STM32U545 A/B descriptor-page layout. +//! +//! Takes the three raw firmware binaries (immutable boot stage, secure app, +//! non-secure app), a firmware version, a security counter, and a signing backend, +//! and lays out one flashable physical-bank image whose committed bytes are +//! self-verifying and bootable-shaped. The signing reuses +//! [`crate::build_signed_image`], so the signed bytes are identical to what the +//! device verifies. +//! +//! # The on-flash contract (mirrors crates/boot-stage/src/health.rs) +//! +//! Per bank the page size is `0x2000` and the layout is: +//! +//! ```text +//! pages 0-1 metadata (left erased 0xFF, initial state) +//! pages 2-8 boot stage offset 0x4000 +//! page 9 descriptor offset 0x12000: header[0:24] then sig[24:88] +//! pages 10-19 secure app offset 0x14000, exactly 0x14000 bytes (80K) +//! pages 20-31 NS app offset 0x28000, up to 0x18000 bytes (96K) +//! ``` +//! +//! The signed file stays contiguous `HEADER || PAYLOAD || SIG`. PAYLOAD is the +//! secure app padded to exactly `SECURE_LEN`, then the NS app, so +//! `payload_len = SECURE_LEN + ns_len`. The device carves +//! `secure_take = min(payload_len, SECURE_LEN)` then the remainder as NS, so the +//! secure part must be exactly `SECURE_LEN` or the NS band is miscarved. The device +//! reads back the full secure band, so the pad bytes are part of the signed and +//! flashed image: this assembler emits the exact padded bytes rather than relying on +//! erased flash matching a gap fill. +//! +//! # Fill choice +//! +//! Every otherwise-unused byte, including the secure pad and the tail of the NS +//! band, is [`FILL`] = `0xFF`, the erased-flash value. A byte the flash tool skips +//! then reads back identical to the signed image, so the read-back the device +//! verifies matches deterministically. + +use image_verify::HEADER_LEN; +use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; +use image_verify::RootKey; +use image_verify::SIG_LEN; +use image_verify::VerifyError; +use image_verify::verify_image; + +use crate::ImageSigner; +use crate::SignError; +use crate::build_signed_image; + +/// The flash page (and erase) granularity in bytes. RM0456 sec 7.3.1 (DUALBANK=1). +pub const PAGE_SIZE: usize = 0x2000; + +/// The physical bank size in bytes: 32 pages of `PAGE_SIZE`. +pub const BANK_SIZE: usize = PAGE_SIZE * 32; + +/// The erased-flash fill byte used for every unwritten region and every pad. +pub const FILL: u8 = 0xFF; + +/// Boot-stage band offset within the bank (page 2). Link origin 0x0C004000. +pub const BOOT_OFFSET: usize = 2 * PAGE_SIZE; + +/// Boot-stage band capacity in bytes (pages 2-8, below the descriptor). +pub const BOOT_LEN: usize = DESCRIPTOR_OFFSET - BOOT_OFFSET; + +/// Descriptor page offset within the bank (page 9). Link origin 0x0C012000. +pub const DESCRIPTOR_OFFSET: usize = 9 * PAGE_SIZE; + +/// Descriptor payload length: the signed header then the signature. +pub const DESCRIPTOR_LEN: usize = HEADER_LEN + SIG_LEN; + +/// Secure app band offset within the bank (page 10). Link origin 0x0C014000. +pub const SECURE_OFFSET: usize = 10 * PAGE_SIZE; + +/// Secure app band length in bytes (pages 10-19). The secure payload is padded to +/// exactly this so the device carves the SECWM boundary correctly. +pub const SECURE_LEN: usize = 10 * PAGE_SIZE; + +/// Non-secure app band offset within the bank (page 20). Link origin 0x08028000. +pub const NS_OFFSET: usize = 20 * PAGE_SIZE; + +/// Non-secure app band length in bytes (pages 20-31). +pub const NS_LEN: usize = 12 * PAGE_SIZE; + +// The bands tile the bank contiguously with no gap and no overlap: the +// descriptor page is immediately followed by the secure band, which is +// immediately followed by the NS band, which closes the bank. A wrong constant +// breaks the build. +const _: () = assert!(DESCRIPTOR_OFFSET + PAGE_SIZE == SECURE_OFFSET); +const _: () = assert!(SECURE_OFFSET + SECURE_LEN == NS_OFFSET); +const _: () = assert!(NS_OFFSET + NS_LEN == BANK_SIZE); +const _: () = assert!(DESCRIPTOR_LEN <= PAGE_SIZE); + +/// Why a bank assembly failed. Every variant is fail-closed: no artifact is +/// produced. No variant carries key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BankError +{ + /// The boot-stage binary exceeds the boot band (pages 2-8). + BootTooLarge + { + /// The boot binary length in bytes. + got: usize, + }, + /// The secure app binary exceeds the secure band (pages 10-19). + SecureTooLarge + { + /// The secure binary length in bytes. + got: usize, + }, + /// The non-secure app binary exceeds the NS band (pages 20-31). + NsTooLarge + { + /// The non-secure binary length in bytes. + got: usize, + }, + /// Signing the assembled payload failed. Carries the signer error. + Sign(SignError), + /// The signer's public key does not equal the pinned root key. The device + /// would reject the image, so no artifact is produced. + PubkeyMismatch, + /// The assembled bank failed the four-segment self-verify against the pinned + /// root key. This must never happen: it means the layout and the signed + /// bytes disagree, so the image is withheld. + SelfVerifyFailed(VerifyError), + /// The external-signature context file is malformed: a wrong magic, a + /// truncated body, or a field that disagrees with the fixed band geometry. + /// The FINALIZE step withholds the bank. + BadContext, + /// The external signature bytes are neither a valid 64-byte raw `(r, s)` pair + /// nor a valid ASN.1 DER ECDSA signature, so FINALIZE cannot proceed. + BadSignatureFormat, + /// The normalized external signature does not verify against the pinned public + /// key over the digest recomputed from the context. A wrong key, a wrong digest, + /// or a corrupt signature all land here. + ExternalSignatureRejected, +} + +impl core::fmt::Display for BankError +{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result + { + match self + { + BankError::BootTooLarge { got } => + { + write!( + f, + "the boot-stage binary is {got} bytes, over the {BOOT_LEN}-byte boot band" + ) + } + BankError::SecureTooLarge { got } => + { + write!( + f, + "the secure app binary is {got} bytes, over the {SECURE_LEN}-byte secure band" + ) + } + BankError::NsTooLarge { got } => + { + write!( + f, + "the non-secure app binary is {got} bytes, over the {NS_LEN}-byte NS band" + ) + } + BankError::Sign(e) => + { + write!(f, "signing the assembled payload failed: {e}") + } + BankError::PubkeyMismatch => + { + write!( + f, + "the signer's public key does not match the pinned root key, \ + the device would reject this image" + ) + } + BankError::SelfVerifyFailed(e) => + { + write!( + f, + "ALARM: the assembled bank failed its own four-segment \ + self-verify ({e:?}), no image was written" + ) + } + BankError::BadContext => + { + write!( + f, + "the external-signature context file is malformed, \ + FINALIZE was withheld" + ) + } + BankError::BadSignatureFormat => + { + write!( + f, + "the external signature is neither a 64-byte raw (r, s) pair \ + nor a valid ASN.1 DER ECDSA signature" + ) + } + BankError::ExternalSignatureRejected => + { + write!( + f, + "the external signature does not verify against the pinned \ + public key over the context digest, no image was written. \ + Common bench causes: the card RE-HASHED the digest (wrong \ + PKCS#11 mechanism, it must sign the 32-byte hash raw), the \ + wrong slot or key was used, or the context and the signature \ + came from DIFFERENT prepare runs" + ) + } + } + } +} + +/// A fully assembled, self-verified physical-bank image. +pub struct AssembledBank +{ + /// The full `BANK_SIZE` flashable image, regions at their physical offsets, + /// every other byte [`FILL`]. + pub image: Vec, + /// The signer's 65-byte uncompressed SEC1 public key, confirmed equal to the + /// pinned root key. + pub public_key: [u8; ROOT_KEY_LEN], + /// The boot-stage binary length placed in the boot band. + pub boot_len: usize, + /// The signed payload length: `SECURE_LEN + ns_len`. + pub payload_len: usize, + /// The actual secure app binary length before padding. + pub secure_len: usize, + /// The non-secure app binary length. + pub ns_len: usize, +} + +/// Assembles the flashable bank image and self-verifies it. +/// +/// # Arguments +/// +/// - `boot`: the immutable boot-stage raw binary (link origin 0x0C004000). +/// - `secure`: the secure app raw binary (link origin 0x0C014000). +/// - `nonsecure`: the non-secure app raw binary (link origin 0x08028000). +/// - `version`: the firmware version embedded in the signed header. +/// - `security_counter`: the monotonic anti-rollback counter embedded. +/// - `signer`: the signing backend over `HEADER || PAYLOAD`. +/// - `expected_root_key`: the pinned root public key the device verifies against. +/// +/// # Returns +/// +/// An [`AssembledBank`] whose image the four-segment device verifier accepts. +/// +/// # Errors +/// +/// A size overflow of any band, a signing failure, a public-key mismatch against +/// the pinned key, or a failed self-verify. The image is withheld on any of them. +pub fn assemble_bank +( + boot: &[u8], + secure: &[u8], + nonsecure: &[u8], + version: ImageVersion, + security_counter: u32, + signer: &dyn ImageSigner, + expected_root_key: &[u8; ROOT_KEY_LEN], +) + -> Result +{ + check_region_sizes(boot, secure, nonsecure)?; + + // The signer's public key must be the pinned root. + let public_key = signer.public_key(); + if &public_key != expected_root_key + { + return Err(BankError::PubkeyMismatch); + } + + // PAYLOAD = secure padded to exactly SECURE_LEN with FILL, then the NS app. Built + // through the same helper the external flow uses, so the byte layout has a single + // source of truth. + let payload = assemble_payload(secure, nonsecure); + let payload_len = payload.len(); + + // Sign through the same path the device verifies, so the header and signature are + // byte-identical to a plain signed file. build_signed_image also runs its own + // contiguous round-trip self-check. + let signed = build_signed_image(&payload, version, security_counter, signer) + .map_err(BankError::Sign)?; + + // Split the contiguous signed file into its three logical parts. + let header: &[u8; HEADER_LEN] = signed + .get(..HEADER_LEN) + .and_then(|h| h.try_into().ok()) + .ok_or(BankError::SelfVerifyFailed(VerifyError::TooShort))?; + let signed_payload = signed + .get(HEADER_LEN..HEADER_LEN + payload_len) + .ok_or(BankError::SelfVerifyFailed(VerifyError::TooShort))?; + let sig: &[u8; SIG_LEN] = signed + .get(HEADER_LEN + payload_len..) + .and_then(|s| s.try_into().ok()) + .ok_or(BankError::SelfVerifyFailed(VerifyError::TooShort))?; + + // Lay the bank out at the physical offsets through the same helper the external + // flow uses. Everything else stays FILL. + let image = place_bank(boot, header, signed_payload, sig)?; + + // Self-verify the assembled bytes as the device carves and verifies them, + // against the pinned root key. + let root = RootKey::from_bytes(*expected_root_key) + .map_err(BankError::SelfVerifyFailed)?; + verify_bank_segments(&image, &root).map_err(BankError::SelfVerifyFailed)?; + + Ok(AssembledBank + { + image, + public_key, + boot_len: boot.len(), + payload_len, + secure_len: secure.len(), + ns_len: nonsecure.len(), + }) +} + +/// Checks each firmware region fits its physical band. Shared by the internal +/// bring-up sign and the external-signature flow so the size policy is stated +/// once. +/// +/// # Errors +/// +/// [`BankError::BootTooLarge`], [`BankError::SecureTooLarge`], or +/// [`BankError::NsTooLarge`] if the matching region overflows its band. +pub(crate) fn check_region_sizes +( + boot: &[u8], + secure: &[u8], + nonsecure: &[u8], +) + -> Result<(), BankError> +{ + if boot.len() > BOOT_LEN + { + return Err(BankError::BootTooLarge { got: boot.len() }); + } + if secure.len() > SECURE_LEN + { + return Err(BankError::SecureTooLarge { got: secure.len() }); + } + if nonsecure.len() > NS_LEN + { + return Err(BankError::NsTooLarge { got: nonsecure.len() }); + } + Ok(()) +} + +/// Builds the signed PAYLOAD: the secure app padded to [`SECURE_LEN`] with +/// [`FILL`], then the NS app. `payload_len = SECURE_LEN + nonsecure.len()`. +/// +/// The pad bytes are inside the signed payload because the device reads back the +/// whole secure band. The caller must have run [`check_region_sizes`] first, so the +/// two copies are in range. This is the single source of truth for the payload +/// bytes, shared by [`assemble_bank`] and the external-signature flow. +pub(crate) fn assemble_payload(secure: &[u8], nonsecure: &[u8]) -> Vec +{ + let payload_len = SECURE_LEN + nonsecure.len(); + let mut payload = vec![FILL; payload_len]; + // Both copies are bounded by check_region_sizes. + if let Some(slot) = payload.get_mut(..secure.len()) + { + slot.copy_from_slice(secure); + } + if let Some(slot) = payload.get_mut(SECURE_LEN..) + { + slot.copy_from_slice(nonsecure); + } + payload +} + +/// Lays the descriptor, boot, and the two payload bands out at their physical +/// offsets in a fresh [`BANK_SIZE`] image, every other byte [`FILL`]. +/// +/// This is the single source of truth for the bank layout, shared by +/// [`assemble_bank`] and the external-signature flow, so the two paths cannot drift. +/// `signed_payload` is the secure band (padded to [`SECURE_LEN`]) then the NS app, +/// exactly as [`assemble_payload`] built it. +/// +/// # Errors +/// +/// [`BankError::BootTooLarge`] if the boot region overflows its band, +/// [`BankError::NsTooLarge`] if the NS remainder overflows its band, or +/// [`BankError::SelfVerifyFailed`] if `signed_payload` is shorter than the secure +/// band. +pub(crate) fn place_bank +( + boot: &[u8], + header: &[u8; HEADER_LEN], + signed_payload: &[u8], + sig: &[u8; SIG_LEN], +) + -> Result, BankError> +{ + if boot.len() > BOOT_LEN + { + return Err(BankError::BootTooLarge { got: boot.len() }); + } + let ns_len = signed_payload + .len() + .checked_sub(SECURE_LEN) + .ok_or(BankError::SelfVerifyFailed(VerifyError::LengthMismatch))?; + if ns_len > NS_LEN + { + return Err(BankError::NsTooLarge { got: ns_len }); + } + + let mut image = vec![FILL; BANK_SIZE]; + // Every slice below is proven in range by the checks above and by the + // compile-time band tiling asserts, so `?` on the get_mut is defensive only. + copy_into(&mut image, BOOT_OFFSET, boot)?; + copy_into(&mut image, DESCRIPTOR_OFFSET, header)?; + copy_into(&mut image, DESCRIPTOR_OFFSET + HEADER_LEN, sig)?; + copy_into(&mut image, SECURE_OFFSET, &signed_payload[..SECURE_LEN])?; + copy_into(&mut image, NS_OFFSET, &signed_payload[SECURE_LEN..])?; + Ok(image) +} + +// Copies `src` into `image` at `offset`, or returns SelfVerifyFailed if the +// destination window is out of range. A panic-free wrapper over copy_from_slice. +fn copy_into +( + image: &mut [u8], + offset: usize, + src: &[u8], +) + -> Result<(), BankError> +{ + image + .get_mut(offset..offset + src.len()) + .ok_or(BankError::SelfVerifyFailed(VerifyError::TooShort))? + .copy_from_slice(src); + Ok(()) +} + +/// Verifies a bank image the way the device does: carve four segments from the +/// descriptor page and the two bands, then run the segmented verifier. +/// +/// This mirrors `crates/boot-stage/src/health.rs::assess` byte for byte: the +/// header and signature come from the page-9 descriptor, the secure payload from +/// the full secure band, the NS payload from the NS band, cut by the header's +/// declared `payload_len`. +/// +/// # Errors +/// +/// Any [`VerifyError`] the segmented verifier raises, or [`VerifyError::TooShort`] +/// if the bank is too small to hold the bands. +pub(crate) fn verify_bank_segments +( + bank: &[u8], + root: &RootKey, +) + -> Result<(), VerifyError> +{ + let descriptor = bank + .get(DESCRIPTOR_OFFSET..DESCRIPTOR_OFFSET + DESCRIPTOR_LEN) + .ok_or(VerifyError::TooShort)?; + let secure_band = bank + .get(SECURE_OFFSET..SECURE_OFFSET + SECURE_LEN) + .ok_or(VerifyError::TooShort)?; + let ns_band = bank + .get(NS_OFFSET..NS_OFFSET + NS_LEN) + .ok_or(VerifyError::TooShort)?; + + let header = descriptor.get(..HEADER_LEN).ok_or(VerifyError::TooShort)?; + let sig = descriptor + .get(HEADER_LEN..HEADER_LEN + SIG_LEN) + .ok_or(VerifyError::TooShort)?; + + // payload_len is the little-endian u32 at header offset 18. Reading it from the + // not-yet-verified header is safe: the signature binds the true length, so a lie + // yields a wrong digest or a bounds rejection. + let len_bytes: [u8; 4] = header + .get(18..22) + .and_then(|b| b.try_into().ok()) + .ok_or(VerifyError::TooShort)?; + let payload_len = u32::from_le_bytes(len_bytes) as usize; + + let secure_take = core::cmp::min(payload_len, secure_band.len()); + let ns_take = payload_len + .checked_sub(secure_take) + .ok_or(VerifyError::LengthMismatch)?; + let secure_seg = secure_band + .get(..secure_take) + .ok_or(VerifyError::TooShort)?; + let ns_seg = ns_band.get(..ns_take).ok_or(VerifyError::LengthMismatch)?; + + let segments: [&[u8]; 4] = [header, secure_seg, ns_seg, sig]; + verify_image(&segments, root).map(|_| ()) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::SoftwareSigner; + use crate::derive_public_key; + use sha2::Digest; + use sha2::Sha256; + + // The bring-up phrase. Must match crates/boot-stage/src/mock.rs BRINGUP_PHRASE. + // Any drift is caught by the derivation test below. + const BRINGUP_PHRASE: &[u8] = + b"patina_key MCU image root - BRING-UP ONLY - replace at ceremony freeze"; + + const BRINGUP_ROOT_KEY: [u8; ROOT_KEY_LEN] = [ + 0x04, 0x41, 0xf2, 0xde, 0xd6, 0xe6, 0x07, 0xa0, + 0xe0, 0x6c, 0x41, 0xc2, 0xcf, 0xab, 0x37, 0xf5, + 0xd7, 0x14, 0x90, 0x76, 0x31, 0x14, 0xbd, 0xaa, + 0xf4, 0x1c, 0x87, 0x8c, 0x25, 0xd3, 0xbb, 0x29, + 0x50, 0xbf, 0x26, 0x1e, 0xfb, 0x05, 0xb5, 0xbd, + 0x01, 0x1d, 0xbe, 0x67, 0xd6, 0x3c, 0xdc, 0xc4, + 0x8b, 0x82, 0x0a, 0x64, 0xf7, 0xa3, 0xd5, 0x85, + 0x8c, 0x76, 0xd7, 0x42, 0x24, 0x08, 0xba, 0xfe, + 0xe1, + ]; + + fn bringup_scalar() -> [u8; 32] + { + Sha256::digest(BRINGUP_PHRASE).into() + } + + fn bringup_signer() -> SoftwareSigner + { + SoftwareSigner::from_key(&bringup_scalar()).expect("bring-up scalar valid") + } + + fn version() -> ImageVersion + { + ImageVersion + { + major: 0, + minor: 0, + revision: 1, + build: 0, + } + } + + // These literal offsets, lengths, and addresses must match + // crates/mcu-flash/src/regs.rs (IMAGE_*) and crates/boot-stage/src/health.rs. The + // host workspace cannot import the thumbv8m mcu-flash crate, so the + // cross-workspace agreement stays a manual check, but this test pins bank.rs's own + // derived layout to explicit values, so an internal drift (a changed page count or + // PAGE_SIZE) fails here. Re-verify these literals against + // regs.rs on any layout change. + #[test] + fn the_geometry_matches_the_pinned_device_layout() + { + assert_eq!(PAGE_SIZE, 0x2000, "8 KB page (regs.rs PAGE_SIZE)"); + assert_eq!(BANK_SIZE, 0x40000, "32 pages per bank (256 KB)"); + assert_eq!(BOOT_OFFSET, 0x4000, "boot band at page 2"); + assert_eq!(BOOT_LEN, 0xE000, "boot band pages 2-8 (56 KB)"); + assert_eq!(DESCRIPTOR_OFFSET, 0x12000, "descriptor at page 9"); + assert_eq!(DESCRIPTOR_LEN, 88, "header 24 + signature 64"); + assert_eq!(SECURE_OFFSET, 0x14000, "secure band at page 10"); + assert_eq!(SECURE_LEN, 0x14000, "secure band pages 10-19 (80 KB)"); + assert_eq!(NS_OFFSET, 0x28000, "NS band at page 20"); + assert_eq!(NS_LEN, 0x18000, "NS band pages 20-31 (96 KB)"); + } + + // The tool's bring-up derivation reproduces the bring-up root key exactly. This + // is the in-crate guard against phrase drift. + #[test] + fn bringup_derivation_matches_the_bringup_key() + { + let derived = derive_public_key(&bringup_scalar()).expect("valid scalar"); + assert_eq!(derived, BRINGUP_ROOT_KEY); + } + + // A representative assembly self-verifies, and the reported geometry matches. + #[test] + fn a_representative_bank_self_verifies() + { + let boot = vec![0xA5u8; 4096]; + let secure = vec![0x11u8; 6000]; + let ns = vec![0x22u8; 3000]; + let bank = assemble_bank + ( + &boot, + &secure, + &ns, + version(), + 7, + &bringup_signer(), + &BRINGUP_ROOT_KEY, + ) + .expect("assembly must succeed and self-verify"); + + assert_eq!(bank.image.len(), BANK_SIZE); + assert_eq!(bank.public_key, BRINGUP_ROOT_KEY); + assert_eq!(bank.secure_len, 6000); + assert_eq!(bank.ns_len, 3000); + assert_eq!(bank.payload_len, SECURE_LEN + 3000); + + // The regions landed at their physical offsets. + assert_eq!(&bank.image[BOOT_OFFSET..BOOT_OFFSET + 4096], &boot[..]); + assert_eq!(&bank.image[SECURE_OFFSET..SECURE_OFFSET + 6000], &secure[..]); + assert_eq!(&bank.image[NS_OFFSET..NS_OFFSET + 3000], &ns[..]); + // The secure pad past the app is FILL. + assert!( + bank.image[SECURE_OFFSET + 6000..NS_OFFSET] + .iter() + .all(|&b| b == FILL) + ); + // Metadata pages 0-1 stay erased. + assert!(bank.image[..BOOT_OFFSET].iter().all(|&b| b == FILL)); + } + + // A wrong expected root key is rejected before any layout, with no artifact. + #[test] + fn a_wrong_pinned_key_is_rejected() + { + let mut wrong = BRINGUP_ROOT_KEY; + wrong[10] ^= 0x01; + let result = assemble_bank( + b"boot", + b"secure", + b"ns", + version(), + 0, + &bringup_signer(), + &wrong, + ); + assert_eq!(result.err(), Some(BankError::PubkeyMismatch)); + } + + // Assembles a good bank, then proves a one-byte corruption of each region makes + // the four-segment verify reject. This is the non-vacuity proof. + fn good_bank() -> Vec + { + assemble_bank( + &vec![0xA5u8; 4096], + &vec![0x11u8; 6000], + &vec![0x22u8; 3000], + version(), + 7, + &bringup_signer(), + &BRINGUP_ROOT_KEY, + ) + .expect("assembly") + .image + } + + #[test] + fn a_valid_bank_verifies_but_corruption_of_any_region_rejects() + { + let root = RootKey::from_bytes(BRINGUP_ROOT_KEY).expect("root"); + let base = good_bank(); + assert!(verify_bank_segments(&base, &root).is_ok()); + + // One byte in each of the four device-read regions. + for &off in &[ + DESCRIPTOR_OFFSET, // header + DESCRIPTOR_OFFSET + HEADER_LEN, // signature + SECURE_OFFSET, // secure payload + NS_OFFSET, // NS payload + ] + { + let mut corrupt = base.clone(); + corrupt[off] ^= 0xFF; + assert!( + verify_bank_segments(&corrupt, &root).is_err(), + "corruption at offset {off:#x} must be rejected" + ); + } + + // A byte in the secure pad (past the app, still inside the signed band). + let mut pad_corrupt = base.clone(); + pad_corrupt[SECURE_OFFSET + SECURE_LEN - 1] ^= 0xFF; + assert! + ( + verify_bank_segments(&pad_corrupt, &root).is_err(), + "corruption in the signed secure pad must be rejected" + ); + } + + // A payload whose secure part is padded to the wrong length miscarves on the + // device and must be rejected. This assembles a deliberately mis-sized bank by + // hand (bypassing assemble_bank's exact padding) and proves the self-verify + // catches it. + #[test] + fn a_wrong_secure_pad_size_is_rejected() + { + let signer = bringup_signer(); + let secure = vec![0x11u8; 6000]; + let ns = vec![0x22u8; 3000]; + + // Pad the secure part to SECURE_LEN - 8 instead of SECURE_LEN, so the + // signed payload_len is 8 short of the correct split. + let bad_secure_len = SECURE_LEN - 8; + let payload_len = bad_secure_len + ns.len(); + let mut payload = vec![FILL; payload_len]; + payload[..secure.len()].copy_from_slice(&secure); + payload[bad_secure_len..].copy_from_slice(&ns); + + let signed = build_signed_image(&payload, version(), 7, &signer) + .expect("build"); + let header = &signed[..HEADER_LEN]; + let signed_payload = &signed[HEADER_LEN..HEADER_LEN + payload_len]; + let sig = &signed[HEADER_LEN + payload_len..]; + + // A real assembler places the NS region page-aligned at NS_OFFSET. With the + // secure part signed 8 bytes short, the secure region leaves an 8-byte FILL + // gap the device folds into the 80K secure band, and the NS split shifts, so + // the reconstructed image no longer matches the signed bytes. + let mut image = vec![FILL; BANK_SIZE]; + image[DESCRIPTOR_OFFSET..DESCRIPTOR_OFFSET + HEADER_LEN] + .copy_from_slice(header); + image[DESCRIPTOR_OFFSET + HEADER_LEN..DESCRIPTOR_OFFSET + DESCRIPTOR_LEN] + .copy_from_slice(sig); + image[SECURE_OFFSET..SECURE_OFFSET + bad_secure_len] + .copy_from_slice(&signed_payload[..bad_secure_len]); + image[NS_OFFSET..NS_OFFSET + ns.len()] + .copy_from_slice(&signed_payload[bad_secure_len..]); + + let root = RootKey::from_bytes(BRINGUP_ROOT_KEY).expect("root"); + assert!( + verify_bank_segments(&image, &root).is_err(), + "a secure part not padded to exactly SECURE_LEN must be rejected" + ); + } + + // An oversize secure binary is refused with no artifact. + #[test] + fn an_oversize_secure_binary_is_refused() + { + let secure = vec![0u8; SECURE_LEN + 1]; + let result = assemble_bank( + b"boot", + &secure, + b"ns", + version(), + 0, + &bringup_signer(), + &BRINGUP_ROOT_KEY, + ); + assert_eq!( + result.err(), + Some(BankError::SecureTooLarge { got: SECURE_LEN + 1 }) + ); + } +} diff --git a/tools/image-signer/src/external.rs b/tools/image-signer/src/external.rs new file mode 100644 index 0000000..395fd6f --- /dev/null +++ b/tools/image-signer/src/external.rs @@ -0,0 +1,696 @@ +//! External-signature two-step flow: prepare then finalize. +//! +//! Splits bank assembly around a signature made by an offline signer +//! (a YubiKey PIV slot), so the private key never touches this tool. +//! +//! # Prepare +//! +//! [`prepare_external`] takes the three firmware images plus the version and +//! security-counter fields, builds `HEADER || PAYLOAD` exactly as +//! [`crate::assemble_bank`] does, and returns: +//! +//! - `digest` = `SHA-256(HEADER || PAYLOAD)`, the 32 bytes the operator signs. +//! - `context`, a self-describing blob holding the boot bytes, the header, and the +//! payload, so finalize reconstructs the bank without re-running objcopy or re-assembling. +//! +//! The digest is what the device hashes too: the device streams +//! `SHA-256(header || secure_band || ns)`, and the payload is the secure band +//! (padded to `SECURE_LEN`) then the NS app, so the two digests are equal. +//! +//! # The operator signs the digest, raw ECDSA, no re-hash +//! +//! The 32-byte digest is signed as a raw ECDSA P-256 signature over the hash. The +//! card signs the hash bytes directly and must not hash them again. On a YubiKey +//! that is a touch plus PIN operation. +//! +//! # Finalize +//! +//! [`finalize_external`] takes the context, the external signature, and the pinned +//! public key. It parses the signature (raw 64-byte `r || s` or ASN.1 DER), +//! normalizes it to low-s (the device rejects high-s, an external signer emits it +//! about half the time), verifies it against the pinned key over the digest +//! recomputed from the context, then lays out the bank and runs the same +//! four-segment self-verify [`crate::assemble_bank`] runs. Any failure withholds the +//! bank. + +use image_verify::HEADER_LEN; +use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; +use image_verify::RootKey; +use image_verify::SIG_LEN; +use image_verify::VerifyError; +use image_verify::encode_header; +use p256::ecdsa::Signature; +use p256::ecdsa::VerifyingKey; +use p256::ecdsa::signature::hazmat::PrehashVerifier; +use sha2::Digest; +use sha2::Sha256; + +use crate::AssembledBank; +use crate::BankError; +use crate::SignError; +use crate::bank::BOOT_LEN; +use crate::bank::NS_LEN; +use crate::bank::SECURE_LEN; +use crate::bank::assemble_payload; +use crate::bank::check_region_sizes; +use crate::bank::place_bank; +use crate::bank::verify_bank_segments; + +/// Length of the digest the operator signs: a SHA-256 output, 32 bytes. +pub const DIGEST_LEN: usize = 32; + +// The context blob magic, ASCII "PKXCTX01" (Patina Key eXternal ConTeXt, format +// 01). Compared byte for byte, so byte order does not apply. +const CONTEXT_MAGIC: [u8; 8] = *b"PKXCTX01"; + +// The fixed context header: the 8-byte magic then five little-endian u32 length +// fields (boot, header, payload, secure, ns). +const CONTEXT_FIXED_LEN: usize = 8 + 4 * 5; + +/// The output of the prepare step. +/// +/// `digest` is signed offline. `context` is fed back into [`finalize_external`] +/// unchanged. None of it is secret: the images, the header, and the digest are +/// all public. +pub struct PreparedExternal +{ + /// `SHA-256(HEADER || PAYLOAD)`, the 32 bytes the operator signs with the + /// YubiKey as a raw ECDSA P-256 signature (no re-hash). + pub digest: [u8; DIGEST_LEN], + /// The self-describing context blob finalize consumes. + pub context: Vec, + /// The signed payload length: `SECURE_LEN + ns_len`. + pub payload_len: usize, + /// The actual secure app length before padding. + pub secure_len: usize, + /// The non-secure app length. + pub ns_len: usize, +} + +/// How the external signature file is encoded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigFormat +{ + /// A bare 64-byte `r || s` pair, two 32-byte big-endian scalars. + Raw, + /// An ASN.1 DER `SEQUENCE` of two `INTEGER`s (r, s), what openssl and a PIV + /// or PKCS#11 toolchain emit by default. + Der, + /// Decide by length: exactly 64 bytes is treated as raw `r || s`, anything else + /// is parsed as DER. A 64-byte DER encoding is astronomically rare, and one seen + /// here would be misread as raw and then fail closed at verify, never accepted. + /// Pass `--sig-format der` to force DER. + Auto, +} + +/// Builds the digest and the context for the external-signature flow. +/// +/// The bytes match [`crate::assemble_bank`] exactly, because the payload and the +/// header are built through the same helpers. See the module docs. +/// +/// # Errors +/// +/// A band-size overflow ([`BankError::BootTooLarge`], +/// [`BankError::SecureTooLarge`], [`BankError::NsTooLarge`]) or a payload that +/// overflows the 32-bit header length field ([`BankError::Sign`] with +/// [`SignError::PayloadTooLarge`]). +pub fn prepare_external +( + boot: &[u8], + secure: &[u8], + nonsecure: &[u8], + version: ImageVersion, + security_counter: u32, +) + -> Result +{ + check_region_sizes(boot, secure, nonsecure)?; + + let payload = assemble_payload(secure, nonsecure); + let payload_len_u32: u32 = payload + .len() + .try_into() + .map_err(|_| BankError::Sign(SignError::PayloadTooLarge))?; + let header = encode_header(version, security_counter, payload_len_u32); + + // DIGEST = SHA-256(HEADER || PAYLOAD), exactly what the device streams. + let mut hasher = Sha256::new(); + hasher.update(header); + hasher.update(&payload); + let digest: [u8; DIGEST_LEN] = hasher.finalize().into(); + + let context = + serialize_context(boot, &header, &payload, secure.len(), nonsecure.len()); + + Ok(PreparedExternal + { + digest, + context, + payload_len: payload.len(), + secure_len: secure.len(), + ns_len: nonsecure.len(), + }) +} + +/// Parses the external signature bytes into an ECDSA signature. +/// +/// Accepts a bare 64-byte `r || s` pair and an ASN.1 DER encoding, chosen by +/// `format`. The result is not yet low-s normalized: [`finalize_external`] +/// normalizes it. +/// +/// # Errors +/// +/// [`BankError::BadSignatureFormat`] if the bytes are not a well-formed signature +/// in the requested (or detected) encoding. +pub fn parse_signature +( + bytes: &[u8], + format: SigFormat, +) + -> Result +{ + match format + { + SigFormat::Raw => + { + Signature::from_slice(bytes) + .map_err(|_| BankError::BadSignatureFormat) + } + SigFormat::Der => + { + Signature::from_der(bytes) + .map_err(|_| BankError::BadSignatureFormat) + } + SigFormat::Auto => + { + if bytes.len() == SIG_LEN + { + Signature::from_slice(bytes) + .map_err(|_| BankError::BadSignatureFormat) + } + else + { + Signature::from_der(bytes) + .map_err(|_| BankError::BadSignatureFormat) + } + } + } +} + +/// Assembles the flashable bank from a context and an external signature. +/// +/// The signature is normalized to low-s, verified against `expected_root_key` +/// over the digest recomputed from `context`, then laid out and self-verified the +/// way the device carves and checks the bank. See the module docs. +/// +/// # Errors +/// +/// [`BankError::BadContext`] for a malformed context, +/// [`BankError::ExternalSignatureRejected`] if the normalized signature does not +/// verify against the pinned key (wrong key, wrong digest, corrupt signature), +/// [`BankError::SelfVerifyFailed`] if the pinned key is off-curve or the assembled +/// bank fails its own four-segment verify. The bank is withheld on any of them. +pub fn finalize_external +( + context: &[u8], + signature: &Signature, + expected_root_key: &[u8; ROOT_KEY_LEN], +) + -> Result +{ + let parsed = parse_context(context)?; + + // Normalize to low-s. (r, n - s) authenticates the same digest, and the device + // accepts only the low-s encoding. An external signer emits high-s about half the + // time, so this is load-bearing, not cosmetic. + let normalized = signature.normalize_s(); + let sig_bytes: [u8; SIG_LEN] = normalized.to_bytes().into(); + + // Recompute the digest from the context. This is the one digest finalize trusts: + // it is derived from the exact header and payload the bank will hold, never taken + // from an external file. + let mut hasher = Sha256::new(); + hasher.update(parsed.header); + hasher.update(parsed.payload); + let digest = hasher.finalize(); + + // Validate the pinned key up front, then verify the normalized signature against + // it over the digest. A mismatch fails closed and withholds the bank. Both + // library types are parsed from the same pinned key bytes: RootKey for the + // four-segment self-verify, VerifyingKey for the prehash verify here. + let root = RootKey::from_bytes(*expected_root_key) + .map_err(BankError::SelfVerifyFailed)?; + let verifying = VerifyingKey::from_sec1_bytes(expected_root_key) + .map_err(|_| BankError::SelfVerifyFailed(VerifyError::BadRootKey))?; + verifying + .verify_prehash(&digest, &normalized) + .map_err(|_| BankError::ExternalSignatureRejected)?; + + // Lay the bank out through the shared helper, then run the same four-segment + // self-verify assemble_bank runs. A layout bug fails here. + let image = place_bank(parsed.boot, parsed.header, parsed.payload, &sig_bytes)?; + verify_bank_segments(&image, &root).map_err(BankError::SelfVerifyFailed)?; + + Ok(AssembledBank + { + image, + public_key: *expected_root_key, + boot_len: parsed.boot.len(), + payload_len: parsed.payload.len(), + secure_len: parsed.secure_len, + ns_len: parsed.ns_len, + }) +} + +// The three verbatim regions plus the two actual lengths, borrowed out of a +// context blob. The header is a fixed-size reference so place_bank needs no +// re-check of its length. +struct ParsedContext<'a> +{ + boot: &'a [u8], + header: &'a [u8; HEADER_LEN], + payload: &'a [u8], + secure_len: usize, + ns_len: usize, +} + +// Serializes the context: the magic, five little-endian u32 length fields, then the +// boot, header, and payload bytes verbatim. Every length is bounded by +// check_region_sizes. +fn serialize_context +( + boot: &[u8], + header: &[u8; HEADER_LEN], + payload: &[u8], + secure_len: usize, + ns_len: usize, +) + -> Vec +{ + let mut out = Vec::with_capacity( + CONTEXT_FIXED_LEN + boot.len() + header.len() + payload.len(), + ); + out.extend_from_slice(&CONTEXT_MAGIC); + out.extend_from_slice(&len_le(boot.len())); + out.extend_from_slice(&len_le(header.len())); + out.extend_from_slice(&len_le(payload.len())); + out.extend_from_slice(&len_le(secure_len)); + out.extend_from_slice(&len_le(ns_len)); + out.extend_from_slice(boot); + out.extend_from_slice(header); + out.extend_from_slice(payload); + out +} + +// Encodes a checked-small length as a little-endian u32. The value is always +// below a band size, so the truncating cast never loses a bit here. +fn len_le(value: usize) -> [u8; 4] +{ + (value as u32).to_le_bytes() +} + +// Reads a little-endian u32 length field at `offset`, as a usize. +fn read_len(bytes: &[u8], offset: usize) -> Result +{ + let field: [u8; 4] = bytes + .get(offset..offset + 4) + .and_then(|b| b.try_into().ok()) + .ok_or(BankError::BadContext)?; + Ok(u32::from_le_bytes(field) as usize) +} + +// Parses and validates a context blob. Every geometry field is checked against +// the fixed band sizes, so a tampered or truncated context is rejected rather +// than trusted. +fn parse_context(bytes: &[u8]) -> Result, BankError> +{ + if bytes.len() < CONTEXT_FIXED_LEN + { + return Err(BankError::BadContext); + } + if bytes.get(..8) != Some(CONTEXT_MAGIC.as_slice()) + { + return Err(BankError::BadContext); + } + + let boot_len = read_len(bytes, 8)?; + let header_len = read_len(bytes, 12)?; + let payload_len = read_len(bytes, 16)?; + let secure_len = read_len(bytes, 20)?; + let ns_len = read_len(bytes, 24)?; + + // The geometry must agree with the fixed bands, else the context is not one this + // tool produced and finalize must not trust it. + if header_len != HEADER_LEN + || boot_len > BOOT_LEN + || secure_len > SECURE_LEN + || ns_len > NS_LEN + || payload_len != SECURE_LEN + ns_len + { + return Err(BankError::BadContext); + } + + // The body length must be exactly the three regions, with no missing and no + // extra trailing bytes. + let expected = CONTEXT_FIXED_LEN + .checked_add(boot_len) + .and_then(|v| v.checked_add(header_len)) + .and_then(|v| v.checked_add(payload_len)) + .ok_or(BankError::BadContext)?; + if bytes.len() != expected + { + return Err(BankError::BadContext); + } + + let boot_start = CONTEXT_FIXED_LEN; + let header_start = boot_start + boot_len; + let payload_start = header_start + header_len; + + let boot = bytes + .get(boot_start..header_start) + .ok_or(BankError::BadContext)?; + let header: &[u8; HEADER_LEN] = bytes + .get(header_start..payload_start) + .and_then(|h| h.try_into().ok()) + .ok_or(BankError::BadContext)?; + let payload = bytes + .get(payload_start..payload_start + payload_len) + .ok_or(BankError::BadContext)?; + + Ok(ParsedContext + { + boot, + header, + payload, + secure_len, + ns_len, + }) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::SoftwareSigner; + use crate::assemble_bank; + use crate::derive_public_key; + use p256::ecdsa::SigningKey; + use p256::ecdsa::signature::hazmat::PrehashSigner; + + // The all-0x02 dev private scalar, a valid P-256 key, standing in for the + // YubiKey. Publicly known, so every fixture is deterministic. + const KEY: [u8; 32] = [2u8; 32]; + + fn version() -> ImageVersion + { + ImageVersion + { + major: 0, + minor: 0, + revision: 1, + build: 0, + } + } + + fn pubkey() -> [u8; ROOT_KEY_LEN] + { + derive_public_key(&KEY).expect("the dev scalar is valid") + } + + fn signing_key() -> SigningKey + { + SigningKey::from_slice(&KEY).expect("the dev scalar is valid") + } + + // Signs a 32-byte digest as a raw ECDSA signature (prehash, no re-hash), the + // way a YubiKey signs the digest. RFC 6979 deterministic, so the low-s form + // matches assemble_bank's software signer. + fn sign_digest_low_s(digest: &[u8]) -> Signature + { + let sig: Signature = signing_key().sign_prehash(digest).expect("sign"); + sig.normalize_s() + } + + fn sign_digest_high_s(digest: &[u8]) -> Signature + { + let low = sign_digest_low_s(digest); + let (r, s) = low.split_scalars(); + Signature::from_scalars(r, -s).expect("n - s is valid") + } + + fn boot() -> Vec + { + vec![0xA5u8; 4096] + } + + fn secure() -> Vec + { + vec![0x11u8; 6000] + } + + fn nonsecure() -> Vec + { + vec![0x22u8; 3000] + } + + fn prepared() -> PreparedExternal + { + prepare_external(&boot(), &secure(), &nonsecure(), version(), 7) + .expect("prepare must succeed") + } + + // Prepare's digest must equal SHA-256(HEADER || PAYLOAD), the exact value the + // device streams. Recomputed here from the context to cross-check. + #[test] + fn prepare_digest_is_sha256_of_header_and_payload() + { + let p = prepared(); + let ctx = parse_context(&p.context).expect("context parses"); + let mut hasher = Sha256::new(); + hasher.update(ctx.header); + hasher.update(ctx.payload); + let expected: [u8; DIGEST_LEN] = hasher.finalize().into(); + assert_eq!(p.digest, expected); + } + + // A low-s external signature yields a self-verifying bank. + #[test] + fn finalize_accepts_a_low_s_signature() + { + let p = prepared(); + let sig = sign_digest_low_s(&p.digest); + let bank = finalize_external(&p.context, &sig, &pubkey()) + .expect("finalize must accept a low-s signature"); + assert_eq!(bank.image.len(), crate::BANK_SIZE); + assert_eq!(bank.public_key, pubkey()); + } + + // A high-s external signature (what a YubiKey emits half the time) must still + // yield a self-verifying bank, proving the low-s normalization works. + #[test] + fn finalize_normalizes_a_high_s_signature() + { + let p = prepared(); + let high = sign_digest_high_s(&p.digest); + // The input really is high-s, so the normalization is doing real work. + assert!(bool::from( + p256::elliptic_curve::scalar::IsHigh::is_high(&high.s()) + )); + let bank = finalize_external(&p.context, &high, &pubkey()) + .expect("finalize must normalize and accept a high-s signature"); + assert_eq!(bank.image.len(), crate::BANK_SIZE); + } + + // The external path matches the internal path. For the same inputs and key, + // finalize and assemble_bank must produce byte-identical banks. The software + // signer signs the digest deterministically (RFC 6979), the same nonce + // assemble_bank's signer uses, so the low-s signature is identical. + #[test] + fn finalize_matches_assemble_bank_byte_for_byte() + { + let p = prepared(); + let sig = sign_digest_low_s(&p.digest); + let external = finalize_external(&p.context, &sig, &pubkey()) + .expect("finalize"); + + let signer = SoftwareSigner::from_key(&KEY).expect("key"); + let internal = assemble_bank( + &boot(), + &secure(), + &nonsecure(), + version(), + 7, + &signer, + &pubkey(), + ) + .expect("assemble_bank"); + + assert_eq!(external.image, internal.image, "the two paths must agree"); + } + + // The high-s path also lands on the exact assemble_bank bytes, since the + // normalized signature is the same low-s twin. + #[test] + fn finalize_high_s_also_matches_assemble_bank() + { + let p = prepared(); + let high = sign_digest_high_s(&p.digest); + let external = finalize_external(&p.context, &high, &pubkey()) + .expect("finalize"); + + let signer = SoftwareSigner::from_key(&KEY).expect("key"); + let internal = assemble_bank( + &boot(), + &secure(), + &nonsecure(), + version(), + 7, + &signer, + &pubkey(), + ) + .expect("assemble_bank"); + + assert_eq!(external.image, internal.image); + } + + // A signature made by a wrong key must be rejected, with no bank. This pins that + // the accept above is real, not a path that accepts anything. + #[test] + fn finalize_rejects_a_wrong_key_signature() + { + let p = prepared(); + // Sign the correct digest with a different key. + let other = SigningKey::from_slice(&[3u8; 32]).expect("valid scalar"); + let sig: Signature = other.sign_prehash(&p.digest).expect("sign"); + let result = finalize_external(&p.context, &sig, &pubkey()); + assert_eq!(result.err(), Some(BankError::ExternalSignatureRejected)); + } + + // A signature over a different digest must be rejected. The signature is valid + // under the pinned key, only the message is wrong, so only the verify can catch + // it. + #[test] + fn finalize_rejects_a_signature_over_a_different_digest() + { + let p = prepared(); + let wrong_digest = Sha256::digest(b"a different message"); + let sig: Signature = + signing_key().sign_prehash(&wrong_digest).expect("sign"); + let result = finalize_external(&p.context, &sig, &pubkey()); + assert_eq!(result.err(), Some(BankError::ExternalSignatureRejected)); + } + + // A raw signature and its DER encoding of the same (r, s) parse to the same + // signature, so both toolchain outputs are accepted. + #[test] + fn parse_signature_accepts_raw_and_der() + { + let p = prepared(); + let sig = sign_digest_low_s(&p.digest); + let raw_bytes: [u8; SIG_LEN] = sig.to_bytes().into(); + let der_bytes = sig.to_der(); + + let from_raw = parse_signature(&raw_bytes, SigFormat::Raw).expect("raw"); + let from_der = + parse_signature(der_bytes.as_bytes(), SigFormat::Der).expect("der"); + assert_eq!(from_raw, from_der); + + // Auto picks raw for 64 bytes and DER otherwise. + let auto_raw = parse_signature(&raw_bytes, SigFormat::Auto).expect("raw"); + let auto_der = + parse_signature(der_bytes.as_bytes(), SigFormat::Auto).expect("der"); + assert_eq!(auto_raw, auto_der); + } + + // A DER signature drives FINALIZE all the way to a self-verifying bank, the + // openssl / PIV default path. + #[test] + fn finalize_accepts_a_der_signature() + { + let p = prepared(); + let sig = sign_digest_low_s(&p.digest); + let der_bytes = sig.to_der(); + let parsed = + parse_signature(der_bytes.as_bytes(), SigFormat::Auto).expect("der"); + let bank = finalize_external(&p.context, &parsed, &pubkey()) + .expect("finalize accepts a DER signature"); + assert_eq!(bank.image.len(), crate::BANK_SIZE); + } + + // Truncated and garbage signature bytes are rejected in every format. + #[test] + fn parse_signature_rejects_corrupt_bytes() + { + // 63 bytes is one short of a raw pair. + assert_eq!( + parse_signature(&[1u8; 63], SigFormat::Raw).err(), + Some(BankError::BadSignatureFormat) + ); + // All-zero 64 bytes is r = s = 0, not a valid pair. + assert_eq!( + parse_signature(&[0u8; SIG_LEN], SigFormat::Raw).err(), + Some(BankError::BadSignatureFormat) + ); + // Not DER at all. + assert_eq!( + parse_signature(b"not der bytes", SigFormat::Der).err(), + Some(BankError::BadSignatureFormat) + ); + // Auto over an odd length that is not valid DER. + assert_eq!( + parse_signature(&[0xFFu8; 10], SigFormat::Auto).err(), + Some(BankError::BadSignatureFormat) + ); + } + + // A corrupt context is rejected before any crypto: wrong magic, truncation, + // and a tampered length field each fail closed. + #[test] + fn parse_context_rejects_a_corrupt_context() + { + let good = prepared().context; + + // Wrong magic. + let mut bad_magic = good.clone(); + bad_magic[0] ^= 0xFF; + assert_eq!(parse_context(&bad_magic).err(), Some(BankError::BadContext)); + + // Truncated body. + let truncated = &good[..good.len() - 1]; + assert_eq!(parse_context(truncated).err(), Some(BankError::BadContext)); + + // A tampered payload-length field no longer matches the body length. + let mut bad_len = good.clone(); + bad_len[16] ^= 0x01; + assert_eq!(parse_context(&bad_len).err(), Some(BankError::BadContext)); + + // Shorter than even the fixed header. + assert_eq!(parse_context(&[0u8; 4]).err(), Some(BankError::BadContext)); + } + + // FINALIZE over a corrupt context fails closed, no bank. + #[test] + fn finalize_rejects_a_corrupt_context() + { + let p = prepared(); + let sig = sign_digest_low_s(&p.digest); + let mut bad = p.context.clone(); + bad[0] ^= 0xFF; + let result = finalize_external(&bad, &sig, &pubkey()); + assert_eq!(result.err(), Some(BankError::BadContext)); + } + + // An oversize secure image is refused at PREPARE, with no digest. + #[test] + fn prepare_refuses_an_oversize_secure_image() + { + let big = vec![0u8; SECURE_LEN + 1]; + let result = + prepare_external(&boot(), &big, &nonsecure(), version(), 0); + assert_eq!( + result.err(), + Some(BankError::SecureTooLarge { got: SECURE_LEN + 1 }) + ); + } +} diff --git a/tools/image-signer/src/lib.rs b/tools/image-signer/src/lib.rs index 465078d..b1c6df8 100644 --- a/tools/image-signer/src/lib.rs +++ b/tools/image-signer/src/lib.rs @@ -1,51 +1,108 @@ //! Host-side signing library for the patina_key signed firmware-image format. //! -//! It builds a complete `HEADER || PAYLOAD || SIGNATURE` image from a payload, a +//! Builds a complete `HEADER || PAYLOAD || SIGNATURE` image from a payload, a //! firmware version, a security counter, and a signing backend. The header bytes //! come from `image_verify::encode_header`, so the layout has a single source of -//! truth. After signing, the library RE-VERIFIES its own output with -//! `image_verify::verify_image` so a malformed image can never leave the tool. +//! truth. After signing, the output is re-verified with +//! `image_verify::verify_image`, so a malformed image cannot leave the tool. //! //! # Key model //! -//! The PRIVATE key signs here, offline, on the integrator PC. The matching -//! PUBLIC key is pinned into the firmware and verifies on the device. The private -//! key never touches the device. Moving from a dev key to a production key is a -//! reversible recompile that swaps the pinned public key, not a one-way gate. +//! The signature is ECDSA P-256 over SHA-256. The private key signs here, +//! on the integrator PC. The matching public key is pinned into the firmware and +//! verifies on the device. The private key never touches the device. +//! +//! # Deterministic signing +//! +//! ECDSA needs a fresh, unbiased nonce `k` per signature. A repeated or biased `k` +//! leaks the private key from two signatures. That foot-gun lives entirely on the +//! signing side. +//! +//! [`SoftwareSigner`] derives `k` by RFC 6979 from the private key and the message +//! digest, with no RNG. The same message and key give the same signature, and two +//! different messages cannot collide on a `k`. The randomized signing variants are +//! deliberately not used. //! //! # Backend seam //! -//! [`ImageSigner`] abstracts the signing operation so a future hardware-token -//! backend (a PIV-Ed25519 card) drops in without reworking the caller. Only the -//! software backend [`SoftwareSigner`] ships today. +//! [`ImageSigner`] abstracts the signing operation, so a backend change does not +//! rework the caller. [`SoftwareSigner`] is the only implementation and serves +//! bring-up iteration only. +//! Production signing does NOT go through this trait: the +//! root key lives in a hardware token and never enters this process, so a release +//! uses the two-step external flow ([`prepare_external`] then +//! [`finalize_external`]), where the operator signs the digest offline. +//! +//! # Low-s normalization +//! +//! The device accepts only the low-s encoding of a signature (see +//! `image_verify::verify_image`). A raw ECDSA signer emits high-s about half the +//! time, so [`build_signed_image`] normalizes whatever the backend returns. `(r, s)` +//! and `(r, n - s)` are both valid over the same message, so the normalized form +//! authenticates exactly what the backend signed. Doing it centrally means a future +//! hardware backend needs no special handling. #![forbid(unsafe_code)] -use ed25519_dalek::SigningKey; -use ed25519_dalek::ed25519::signature::Signer; +mod bank; +mod external; + +pub use bank::AssembledBank; +pub use bank::BankError; +pub use bank::assemble_bank; +pub use external::DIGEST_LEN; +pub use external::PreparedExternal; +pub use external::SigFormat; +pub use external::finalize_external; +pub use external::parse_signature; +pub use external::prepare_external; +pub use bank::BANK_SIZE; +pub use bank::BOOT_LEN; +pub use bank::BOOT_OFFSET; +pub use bank::DESCRIPTOR_LEN; +pub use bank::DESCRIPTOR_OFFSET; +pub use bank::FILL; +pub use bank::NS_LEN; +pub use bank::NS_OFFSET; +pub use bank::PAGE_SIZE; +pub use bank::SECURE_LEN; +pub use bank::SECURE_OFFSET; + use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; use image_verify::RootKey; use image_verify::SIG_LEN; use image_verify::encode_header; use image_verify::verify_image; +use p256::ecdsa::Signature; +use p256::ecdsa::SigningKey; +use p256::ecdsa::signature::Signer; use zeroize::Zeroizing; /// Why a signing operation failed. /// -/// Every variant is fail-closed: the tool produces no image on any of them. +/// Every variant is fail-closed: the tool produces no image on any of them. No +/// variant carries key material, so an error can be printed safely. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignError { - /// The supplied seed was not exactly 32 bytes. - BadSeedLength, + /// The supplied private key was not exactly 32 bytes. + BadKeyLength, + /// The 32 bytes are not a valid P-256 private scalar. A scalar must lie in + /// `[1, n-1]`: all-zero and any value at or above the curve order are rejected. + /// Not every 32-byte value is a valid P-256 key, so this check is load-bearing. + InvalidScalar, /// The payload length does not fit in the `u32` header field. PayloadTooLarge, - /// The signer's public key was not a point Ed25519 accepts, so the + /// The signer's public key was not a point the verifier accepts, so the /// round-trip self-check could not even build a root key. BadPublicKey, + /// The backend returned bytes that are not a well-formed `(r, s)` scalar + /// pair, so no image could be assembled. + BadSignatureEncoding, /// The freshly built image failed its own `verify_image` round-trip check. /// This must never happen: it means the encoder, the signer, and the - /// verifier disagree, so the image is withheld. + /// verifier disagree. RoundTripFailed, } @@ -55,9 +112,17 @@ impl core::fmt::Display for SignError { match self { - SignError::BadSeedLength => + SignError::BadKeyLength => + { + write!(f, "the private key was not exactly 32 bytes") + } + SignError::InvalidScalar => { - write!(f, "the seed was not exactly 32 bytes") + write!( + f, + "the 32 bytes are not a valid P-256 private scalar, it must \ + be non-zero and below the curve order" + ) } SignError::PayloadTooLarge => { @@ -65,7 +130,11 @@ impl core::fmt::Display for SignError } SignError::BadPublicKey => { - write!(f, "the signer reported a public key Ed25519 does not accept") + write!(f, "the signer reported a public key the verifier does not accept") + } + SignError::BadSignatureEncoding => + { + write!(f, "the signing backend returned bytes that are not a valid ECDSA (r, s) pair") } SignError::RoundTripFailed => { @@ -80,64 +149,77 @@ impl core::fmt::Display for SignError } } -/// A pluggable Ed25519 signing backend over the firmware-image bytes. +/// A pluggable ECDSA P-256 signing backend over the firmware-image bytes. +/// +/// The signer signs the exact `HEADER || PAYLOAD` message it is handed and reports +/// its own public key, so the caller can pin it and self-check the result. /// -/// The signer signs the exact `HEADER || PAYLOAD` message it is handed and -/// reports its own public key, so the caller can pin it and self-check the -/// result. A future hardware-token backend implements this same trait. +/// # Contract +/// +/// - `sign` returns the 64-byte `r || s` pair, two 32-byte big-endian scalars, with +/// no ASN.1 framing. The nonce must be fresh and unbiased for every message. RFC +/// 6979 determinism satisfies that, as does a hardware token that generates `k` +/// internally. +/// - The s half need not be low-s normalized: [`build_signed_image`] normalizes it. +/// A backend that cannot normalize is still usable. +/// - `public_key` returns the 65-byte uncompressed SEC1 point matching the signing +/// key. +/// +/// A YubiKey PIV backend implements this same trait, so the +/// private key can live in hardware with PIN plus touch enforced. Not implemented +/// here. pub trait ImageSigner { - /// Signs `message` and returns the 64-byte Ed25519 signature. + /// Signs `message` and returns the 64-byte ECDSA `r || s` signature. fn sign(&self, message: &[u8]) -> [u8; SIG_LEN]; - /// Returns the 32-byte Ed25519 public key matching the signing key. - fn public_key(&self) -> [u8; 32]; + /// Returns the 65-byte uncompressed SEC1 public key matching the signing key. + fn public_key(&self) -> [u8; ROOT_KEY_LEN]; } -/// A software Ed25519 signer built from a raw 32-byte seed. +/// A software ECDSA P-256 signer built from a raw 32-byte private scalar. /// -/// The seed is the RFC 8032 Ed25519 private scalar. The tool implements no -/// passphrase crypto: a passphrase-protected seed is decrypted out of band (for -/// example by age or gpg) and handed to the CLI through `--key-file`, as a path -/// or piped from stdin via `-`, then to this signer as raw bytes. That keeps the -/// audit surface minimal. +/// The 32 bytes are the big-endian private scalar `d`, which must lie in `[1, n-1]`. +/// The tool implements no passphrase crypto: a passphrase-protected key is decrypted +/// out of band (for example by age or gpg) and handed to the CLI through +/// `--key-file`, as a path or piped from stdin via `-`, then to this signer as raw +/// bytes. +/// +/// Signing is RFC 6979 deterministic. See the module docs for why that matters. pub struct SoftwareSigner { - // Debug and Clone are intentionally NOT derived because the field is a - // private signing key, so it can never reach logs or be silently - // duplicated. The inner SigningKey zeroizes on drop through ed25519-dalek's - // zeroize feature. key: SigningKey, } impl SoftwareSigner { - /// Builds a software signer from a raw 32-byte Ed25519 seed. + /// Builds a software signer from a raw 32-byte P-256 private scalar. /// - /// This is infallible. Every 32-byte value is a valid Ed25519 seed, and the - /// fixed-size argument makes a wrong length impossible at the type level. - /// The fallible path for a slice of unknown length is - /// [`SoftwareSigner::from_slice`]. - pub fn from_seed(seed: &[u8; 32]) -> SoftwareSigner + /// # Errors + /// + /// Returns [`SignError::InvalidScalar`] if the bytes are not a scalar in + /// `[1, n-1]`. Not every 32-byte value is a valid P-256 private key. + pub fn from_key(key: &[u8; 32]) -> Result { - SoftwareSigner - { - key: SigningKey::from_bytes(seed), - } + let key = SigningKey::from_slice(key) + .map_err(|_| SignError::InvalidScalar)?; + Ok(SoftwareSigner { key }) } - /// Builds a software signer from a seed slice of unknown length. + /// Builds a software signer from a private-key slice of unknown length. /// /// # Errors /// - /// Returns [`SignError::BadSeedLength`] if the slice is not exactly 32 bytes. - pub fn from_slice(seed: &[u8]) -> Result + /// [`SignError::BadKeyLength`] if the slice is not exactly 32 bytes, + /// [`SignError::InvalidScalar`] if those 32 bytes are not a scalar in + /// `[1, n-1]`. + pub fn from_slice(key: &[u8]) -> Result { - let arr: Zeroizing<[u8; 32]> = seed + let arr: Zeroizing<[u8; 32]> = key .try_into() .map(Zeroizing::new) - .map_err(|_| SignError::BadSeedLength)?; - Ok(SoftwareSigner::from_seed(&arr)) + .map_err(|_| SignError::BadKeyLength)?; + SoftwareSigner::from_key(&arr) } } @@ -145,33 +227,54 @@ impl ImageSigner for SoftwareSigner { fn sign(&self, message: &[u8]) -> [u8; SIG_LEN] { - self.key.sign(message).to_bytes() + // Derives the nonce by RFC 6979 from the key and the message digest. No RNG + // runs, and no nonce is reused across two different messages. + let signature: Signature = self.key.sign(message); + let mut out = [0u8; SIG_LEN]; + out.copy_from_slice(&signature.to_bytes()); + out } - fn public_key(&self) -> [u8; 32] + fn public_key(&self) -> [u8; ROOT_KEY_LEN] { - self.key.verifying_key().to_bytes() + let point = self.key.verifying_key().to_sec1_point(false); + let mut out = [0u8; ROOT_KEY_LEN]; + // The uncompressed SEC1 encoding of a P-256 point is exactly 65 bytes. + // On the impossible short branch the buffer stays all-zero, + // which is not a point on the curve, so + // RootKey::from_bytes rejects it and build_signed_image fails closed with + // BadPublicKey rather than emitting a wrong image. + let bytes = point.as_ref(); + if bytes.len() == ROOT_KEY_LEN + { + out.copy_from_slice(bytes); + } + out } } -/// Derives the 32-byte Ed25519 public key from a raw 32-byte seed. +/// Derives the 65-byte uncompressed SEC1 public key from a 32-byte private key. /// -/// The returned bytes are the value to pin into the firmware as the root key. -/// Kept `pub` as a deliberate library API so an external CI consumer can derive -/// the pinned key programmatically, the same value the `derive-pubkey` -/// subcommand prints. -pub fn derive_public_key(seed: &[u8; 32]) -> [u8; 32] +/// The returned bytes are the value to pin into the firmware as the root key. `pub` +/// so an external CI consumer can derive the pinned key programmatically, the same +/// value the `derive-pubkey` subcommand prints. +/// +/// # Errors +/// +/// [`SignError::InvalidScalar`] if the bytes are not a scalar in `[1, n-1]`. +pub fn derive_public_key(key: &[u8; 32]) -> Result<[u8; ROOT_KEY_LEN], SignError> { - SoftwareSigner::from_seed(seed).public_key() + Ok(SoftwareSigner::from_key(key)?.public_key()) } /// Builds a complete signed firmware image and self-checks it. /// -/// The round-trip self-check proves the image is internally consistent under -/// the signer's OWN reported public key. It does NOT prove that key is the one -/// the operator intended. To guard the wrong-key-file error, the caller must -/// compare `signer.public_key()` against an expected value out of band (the -/// `sign` subcommand offers `--expect-pubkey` for that). +/// The signature the backend returns is normalized to low-s before it enters the +/// image, the only encoding the device accepts. The round-trip self-check proves +/// the image is internally consistent under the signer's own reported public key. It +/// does not prove that key is the one the operator intended: to guard a wrong key +/// file, the caller must compare `signer.public_key()` against an expected value out +/// of band (the `sign` subcommand offers `--expect-pubkey` for that). /// /// # Arguments /// @@ -187,6 +290,8 @@ pub fn derive_public_key(seed: &[u8; 32]) -> [u8; 32] /// # Errors /// /// - [`SignError::PayloadTooLarge`] if the payload length exceeds `u32`. +/// - [`SignError::BadSignatureEncoding`] if the backend's bytes are not a valid +/// `(r, s)` pair. /// - [`SignError::BadPublicKey`] if the signer's public key is not on-curve. /// - [`SignError::RoundTripFailed`] if `verify_image` rejects the built image /// under the signer's own public key. The image is withheld on any of these. @@ -206,20 +311,28 @@ pub fn build_signed_image let header = encode_header(version, security_counter, payload_len); - // The signed region is HEADER || PAYLOAD. Build it once, sign it, then - // append the trailing signature. + // The signed region is HEADER || PAYLOAD. Build it once, sign it, then append + // the trailing signature. let mut image = Vec::with_capacity(header.len() + payload.len() + SIG_LEN); image.extend_from_slice(&header); image.extend_from_slice(payload); - let signature = signer.sign(&image); - image.extend_from_slice(&signature); - - // Round-trip self-check: re-verify the bytes we are about to emit under the - // signer's own public key. A malformed image can never leave the tool. + let raw = signer.sign(&image); + let signature = Signature::from_slice(&raw) + .map_err(|_| SignError::BadSignatureEncoding)?; + // Canonicalize to low-s, the only encoding the device accepts. (r, n - s) is as + // valid as (r, s) over the same message, so this changes the bytes and not what + // they authenticate. + let signature = signature.normalize_s(); + image.extend_from_slice(&signature.to_bytes()); + + // Round-trip self-check: re-verify the exact bytes about to be emitted, under the + // signer's own public key, through the same segmented verifier the device runs. A + // malformed image cannot leave the tool. let root = RootKey::from_bytes(signer.public_key()) .map_err(|_| SignError::BadPublicKey)?; - verify_image(&image, &root).map_err(|_| SignError::RoundTripFailed)?; + let segments: [&[u8]; 1] = [&image]; + verify_image(&segments, &root).map_err(|_| SignError::RoundTripFailed)?; Ok(image) } @@ -229,8 +342,14 @@ mod tests { use super::*; - // A fixed seed yields a stable key pair, no RNG. - const SEED: [u8; 32] = [3u8; 32]; + // A fixed private scalar: non-zero and far below the curve order, so it is a + // valid P-256 key and yields a stable key pair with no RNG. + const KEY: [u8; 32] = [3u8; 32]; + + fn signer() -> SoftwareSigner + { + SoftwareSigner::from_key(&KEY).expect("the test scalar is valid") + } fn version() -> ImageVersion { @@ -243,30 +362,47 @@ mod tests } } + // Concatenates the verified payload segments so a test can compare bytes. + fn payload_of(image: &[u8], root: &RootKey) -> Vec + { + let segments: [&[u8]; 1] = [image]; + let verified = verify_image(&segments, root).expect("verify"); + let mut out = Vec::new(); + for piece in verified.payload_segments() + { + out.extend_from_slice(piece); + } + out + } + #[test] fn builds_an_image_the_verifier_accepts() { - let signer = SoftwareSigner::from_seed(&SEED); + let signer = signer(); let payload = b"firmware payload bytes"; let image = build_signed_image(payload, version(), 11, &signer) .expect("build must succeed"); let root = RootKey::from_bytes(signer.public_key()) .expect("public key on-curve"); - let verified = verify_image(&image, &root).expect("verify"); - assert_eq!(verified.payload(), payload); + assert_eq!(payload_of(&image, &root), payload); + + let segments: [&[u8]; 1] = [&image]; + let verified = verify_image(&segments, &root).expect("verify"); assert_eq!(verified.security_counter(), 11); } #[test] fn version_fields_survive_the_round_trip() { - let signer = SoftwareSigner::from_seed(&SEED); - let image = build_signed_image(b"x", version(), 1, &signer) - .expect("build"); + let signer = signer(); + let image = build_signed_image(b"x", version(), 1, &signer).expect("build"); let root = RootKey::from_bytes(signer.public_key()).expect("key"); - let v = verify_image(&image, &root).expect("verify").image_version(); + let segments: [&[u8]; 1] = [&image]; + let v = verify_image(&segments, &root) + .expect("verify") + .image_version(); assert_eq!(v.major, 1); assert_eq!(v.minor, 2); assert_eq!(v.revision, 0x0304); @@ -276,82 +412,209 @@ mod tests #[test] fn empty_payload_round_trips() { - let signer = SoftwareSigner::from_seed(&SEED); + let signer = signer(); let image = build_signed_image(b"", version(), 0, &signer) .expect("build empty"); let root = RootKey::from_bytes(signer.public_key()).expect("key"); - let verified = verify_image(&image, &root).expect("verify"); - assert_eq!(verified.payload(), b""); + assert_eq!(payload_of(&image, &root), b""); } #[test] fn derive_public_key_matches_signer() { - let signer = SoftwareSigner::from_seed(&SEED); - assert_eq!(derive_public_key(&SEED), signer.public_key()); + let signer = signer(); + assert_eq!( + derive_public_key(&KEY).expect("valid scalar"), + signer.public_key() + ); + } + + // The public key is the uncompressed SEC1 encoding: 65 bytes, tag 0x04. This + // pins the encoding the firmware pins. + #[test] + fn the_public_key_is_an_uncompressed_sec1_point() + { + let key = signer().public_key(); + assert_eq!(key.len(), 65); + assert_eq!(key[0], 0x04, "the uncompressed SEC1 tag"); + assert!(RootKey::from_bytes(key).is_ok()); + } + + // An all-zero private key is not a valid P-256 scalar, so it must fail closed. + #[test] + fn an_all_zero_key_is_an_invalid_scalar() + { + assert_eq!( + SoftwareSigner::from_key(&[0u8; 32]).err(), + Some(SignError::InvalidScalar) + ); } + // A key at or above the curve order n is out of range. n itself is the + // smallest such value, so it is the exact boundary case. #[test] - fn dev_seed_derives_the_pinned_dev_root_key() + fn a_key_at_the_curve_order_is_an_invalid_scalar() { - // The all-0x01 dev seed must derive exactly the public key the firmware - // pins for the bench. This pins the tool to the existing dev key model. - const DEV_ROOT_KEY: [u8; 32] = [ - 0x8a, 0x88, 0xe3, 0xdd, 0x74, 0x09, 0xf1, 0x95, - 0xfd, 0x52, 0xdb, 0x2d, 0x3c, 0xba, 0x5d, 0x72, - 0xca, 0x67, 0x09, 0xbf, 0x1d, 0x94, 0x12, 0x1b, - 0xf3, 0x74, 0x88, 0x01, 0xb4, 0x0f, 0x6f, 0x5c, + // n = FFFFFFFF 00000000 FFFFFFFF FFFFFFFF BCE6FAAD A7179E84 F3B9CAC2 FC632551 + let order: [u8; 32] = [ + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, + 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, ]; - assert_eq!(derive_public_key(&[1u8; 32]), DEV_ROOT_KEY); + assert_eq!( + SoftwareSigner::from_key(&order).err(), + Some(SignError::InvalidScalar) + ); + + // All-0xFF is far above n, so it is rejected too. + assert_eq!( + SoftwareSigner::from_key(&[0xFFu8; 32]).err(), + Some(SignError::InvalidScalar) + ); } + // n - 1 is the largest valid scalar, so it must be accepted. #[test] - fn from_slice_rejects_a_short_seed() + fn the_largest_valid_scalar_is_accepted() { - let short = [0u8; 31]; + let order_minus_one: [u8; 32] = [ + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, + 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x50, + ]; + assert!(SoftwareSigner::from_key(&order_minus_one).is_ok()); + } + + #[test] + fn from_slice_rejects_a_short_key() + { + let short = [1u8; 31]; assert_eq!( SoftwareSigner::from_slice(&short).err(), - Some(SignError::BadSeedLength) + Some(SignError::BadKeyLength) ); } #[test] - fn from_slice_rejects_a_long_seed() + fn from_slice_rejects_a_long_key() { - let long = [0u8; 33]; + let long = [1u8; 33]; assert_eq!( SoftwareSigner::from_slice(&long).err(), - Some(SignError::BadSeedLength) + Some(SignError::BadKeyLength) + ); + } + + #[test] + fn from_slice_accepts_exactly_32_valid_bytes() + { + let key = [4u8; 32]; + let signer = SoftwareSigner::from_slice(&key).expect("32 valid bytes ok"); + assert_eq!( + signer.public_key(), + derive_public_key(&key).expect("valid scalar") ); } + // Signing is RFC 6979 deterministic: the same message and key produce the same + // signature, byte for byte. #[test] - fn from_slice_accepts_exactly_32_bytes() + fn signing_is_deterministic() { - let seed = [4u8; 32]; - let signer = SoftwareSigner::from_slice(&seed).expect("32 bytes ok"); - assert_eq!(signer.public_key(), derive_public_key(&seed)); + let signer = signer(); + let message = b"the same message signed twice"; + assert_eq!(signer.sign(message), signer.sign(message)); + assert_ne!(signer.sign(b"message one"), signer.sign(b"message two")); } - // A flipped output byte must fail the round-trip self-check. A signer that - // corrupts its own signature output models a buggy or hostile backend: the - // self-check inside build_signed_image must catch it. Here we test the - // self-check directly by tampering after a clean build, proving verify_image - // rejects what the tool would refuse to emit. + // A whole image built twice is byte-identical, so a release is reproducible. + #[test] + fn the_built_image_is_reproducible() + { + let signer = signer(); + let a = build_signed_image(b"reproducible", version(), 3, &signer) + .expect("build a"); + let b = build_signed_image(b"reproducible", version(), 3, &signer) + .expect("build b"); + assert_eq!(a, b); + } + + // The low-s policy from the signing side. A backend that returns a high-s + // signature must still yield an image the device accepts, because the tool + // normalizes. + #[test] + fn a_high_s_backend_is_normalized_into_an_acceptable_image() + { + struct HighSSigner + { + inner: SoftwareSigner, + } + + impl ImageSigner for HighSSigner + { + fn sign(&self, message: &[u8]) -> [u8; SIG_LEN] + { + let raw = self.inner.sign(message); + let sig = Signature::from_slice(&raw).expect("the inner sig parses"); + let low = sig.normalize_s(); + let (r, s) = low.split_scalars(); + let flipped = Signature::from_scalars(r, -s).expect("n - s is valid"); + let mut out = [0u8; SIG_LEN]; + out.copy_from_slice(&flipped.to_bytes()); + out + } + + fn public_key(&self) -> [u8; ROOT_KEY_LEN] + { + self.inner.public_key() + } + } + + let backend = HighSSigner { inner: signer() }; + let payload = b"high-s backend payload"; + + // The raw backend output really is high-s, so the normalization below has + // real work to do. + let raw = backend.sign(b"probe"); + let probe = Signature::from_slice(&raw).expect("parses"); + assert!( + bool::from(p256::elliptic_curve::scalar::IsHigh::is_high(&probe.s())), + "the test backend must actually emit high-s" + ); + + // The image still builds, and the round-trip self-check inside + // build_signed_image is what proves the device accepts it. + let image = build_signed_image(payload, version(), 1, &backend) + .expect("a high-s backend must still produce a valid image"); + let root = RootKey::from_bytes(backend.public_key()).expect("key"); + assert_eq!(payload_of(&image, &root), payload); + + // And the emitted signature is the low-s twin, byte for byte the same as the + // software signer's own normalized output. + let start = image.len() - SIG_LEN; + let emitted = Signature::from_slice(&image[start..]).expect("parses"); + assert!(!bool::from(p256::elliptic_curve::scalar::IsHigh::is_high(&emitted.s()))); + } + + // A flipped output byte must fail verification, proving the self-check inside + // build_signed_image has something real to catch. #[test] fn a_flipped_output_byte_fails_verification() { - let signer = SoftwareSigner::from_seed(&SEED); + let signer = signer(); let mut image = build_signed_image(b"payload", version(), 1, &signer) .expect("build"); // Flip a payload byte (just past the 24-byte header). image[24] ^= 0xFF; let root = RootKey::from_bytes(signer.public_key()).expect("key"); - assert!(verify_image(&image, &root).is_err()); + let segments: [&[u8]; 1] = [&image]; + assert!(verify_image(&segments, &root).is_err()); } - // A backend that returns a bogus signature must be caught by the self-check, - // so the tool never emits an unverifiable image. + // A backend that returns a well-formed but wrong signature must be caught by the + // self-check, so the tool never emits an unverifiable image. #[test] fn a_lying_signer_is_caught_by_the_self_check() { @@ -364,21 +627,48 @@ mod tests { fn sign(&self, _message: &[u8]) -> [u8; SIG_LEN] { - // A signature that does not match the message at all. - [0u8; SIG_LEN] + // A well-formed (r, s) pair over a different message, so it parses + // cleanly and only the verify can catch it. + self.inner.sign(b"a message that is not the image") } - fn public_key(&self) -> [u8; 32] + fn public_key(&self) -> [u8; ROOT_KEY_LEN] { self.inner.public_key() } } - let signer = LyingSigner - { - inner: SoftwareSigner::from_seed(&SEED), - }; + let signer = LyingSigner { inner: signer() }; let result = build_signed_image(b"payload", version(), 1, &signer); assert_eq!(result.err(), Some(SignError::RoundTripFailed)); } + + // A backend that returns garbage bytes fails at the parse, before any image is + // assembled. + #[test] + fn a_malformed_backend_signature_is_rejected() + { + struct GarbageSigner + { + inner: SoftwareSigner, + } + + impl ImageSigner for GarbageSigner + { + fn sign(&self, _message: &[u8]) -> [u8; SIG_LEN] + { + // r = s = 0 is not a valid scalar pair. + [0u8; SIG_LEN] + } + + fn public_key(&self) -> [u8; ROOT_KEY_LEN] + { + self.inner.public_key() + } + } + + let signer = GarbageSigner { inner: signer() }; + let result = build_signed_image(b"payload", version(), 1, &signer); + assert_eq!(result.err(), Some(SignError::BadSignatureEncoding)); + } } diff --git a/tools/image-signer/src/main.rs b/tools/image-signer/src/main.rs index 4f58d6b..4e13540 100644 --- a/tools/image-signer/src/main.rs +++ b/tools/image-signer/src/main.rs @@ -3,44 +3,99 @@ //! Two subcommands: //! //! - `sign`: signs a firmware binary into a complete signed image. -//! - `derive-pubkey`: prints the public key for a seed, so the operator can pin -//! it into the firmware. +//! - `derive-pubkey`: prints the public key for a private key, so the operator +//! can pin it into the firmware. //! -//! Both subcommands take the 32-byte signing seed through `--key-file `. -//! The value is either a filesystem path or the single character `-`, which -//! reads the seed from STDIN. The stdin form lets a decrypted seed be piped in -//! (for example from `gpg --decrypt`). -//! The tool never accepts the key bytes as a literal argument, so the seed cannot -//! leak through a process listing or shell history. +//! Both subcommands take the 32-byte ECDSA P-256 private key through +//! `--key-file `. The value is either a filesystem path or the single +//! character `-`, which reads the key from stdin. The stdin form lets a decrypted +//! key be piped in (for example from `gpg --decrypt`). The tool never accepts the +//! key bytes as a literal argument, so the key cannot leak through a process listing +//! or shell history. //! -//! Arguments are parsed by hand over `std::env::args`, with NO parsing -//! dependency. Every bad input fails closed: a clear message to stderr and a -//! non-zero exit. The binary never panics on user input. +//! The 32 bytes are the big-endian private scalar `d`, which must lie in `[1, n-1]`. +//! An all-zero file, or any value at or above the curve order, is not a key and is +//! rejected. +//! +//! Arguments are parsed by hand over `std::env::args`. Every bad input fails closed: +//! a clear message to stderr and a non-zero exit. #![forbid(unsafe_code)] use std::env; use std::fs; +use std::fs::OpenOptions; +use std::hash::BuildHasher; +use std::hash::Hasher; use std::io::Read; use std::io::Write; +use std::path::PathBuf; +use std::process::Command; use std::process::ExitCode; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use image_signer::BOOT_OFFSET; +use image_signer::DESCRIPTOR_OFFSET; +use image_signer::DIGEST_LEN; use image_signer::ImageSigner; +use image_signer::NS_LEN; +use image_signer::NS_OFFSET; +use image_signer::SECURE_LEN; +use image_signer::SECURE_OFFSET; +use image_signer::SigFormat; use image_signer::SoftwareSigner; +use image_signer::assemble_bank; use image_signer::build_signed_image; +use image_signer::finalize_external; +use image_signer::parse_signature; +use image_signer::prepare_external; use image_verify::ImageVersion; +use image_verify::ROOT_KEY_LEN; +use sha2::Digest; +use sha2::Sha256; use zeroize::Zeroizing; -// The number of seed bytes the tool accepts. An Ed25519 seed is exactly this -// many bytes (RFC 8032). -const SEED_LEN: usize = 32; +// The number of private-key bytes the tool accepts. A P-256 private scalar is +// exactly this many bytes, big-endian. +const KEY_LEN: usize = 32; + +// The bring-up phrase whose SHA-256 is the bring-up private scalar. This must match +// crates/boot-stage/src/mock.rs BRINGUP_PHRASE. Any drift is caught by +// assemble_bank, which refuses to build unless the derived public key equals the +// pinned root key file, so a stale phrase cannot produce a wrong image. +const BRINGUP_PHRASE: &[u8] = + b"patina_key MCU image root - BRING-UP ONLY - replace at ceremony freeze"; + +// The link origins the three firmware images are built at. A loaded region whose +// vector table does not sit at its origin means a mislinked ELF or a wrong +// objcopy base, which assemble-bank refuses. +const BOOT_ORIGIN: u32 = 0x0C00_4000; +const SECURE_ORIGIN: u32 = 0x0C01_4000; +const NS_ORIGIN: u32 = 0x0802_8000; + +// The two alias views of the physical bank base. Flashing the combined image at +// either address programs the same physical cells. +const BANK_BASE_SECURE: u32 = 0x0C00_0000; +const BANK_BASE_NS: u32 = 0x0800_0000; + +// The lowest and highest valid initial-MSP values: anywhere in the contiguous +// on-chip SRAM. RM0456 memory map (STM32U545): SRAM1+SRAM2+SRAM3+SRAM4 total +// 256 KB (0x40000) from base 0x2000_0000, so the top is 0x2004_0000. This is a +// bench-tool sanity bound on the reset MSP, not a security boundary. +const SRAM_LOW: u32 = 0x2000_0000; +const SRAM_HIGH: u32 = 0x2004_0000; + +// The number of hex chars an uncompressed SEC1 public key prints as: two per byte +// over the 65-byte point. +const PUBKEY_HEX_LEN: usize = ROOT_KEY_LEN * 2; // A clear error string carried up to the top-level handler, which prints it to // stderr and exits non-zero. type ToolError = String; -// The classified result of a stdout write, decided WITHOUT touching any I/O so -// the decision is unit-testable on synthetic errors. +// The classified result of a stdout write, decided without touching any I/O so the +// decision is unit-testable on synthetic errors. // // - Done: the write succeeded. // - ReaderClosed: a broken pipe, the downstream consumer (such as `head`) closed @@ -75,7 +130,7 @@ fn classify_write(result: std::io::Result<()>) -> WriteOutcome // way `println!` does, any other error surfaces as a ToolError that main prints // to stderr with a non-zero exit. // -// Callers MUST drop every secret (the seed, the signer) BEFORE calling this. A +// Callers must drop every secret (the seed, the signer) before calling this. A // broken pipe exits through `process::exit`, which skips destructors, so nothing // secret may still be live at this point. The public key passed in here is not // secret. @@ -88,10 +143,8 @@ fn write_stdout(text: &str) -> Result<(), ToolError> WriteOutcome::Done => Ok(()), WriteOutcome::ReaderClosed => { - // The reader closed its end because it already received the output - // it wanted. Nothing more is wanted, so exit quietly and - // successfully. The bytes from this failed write were NOT delivered, - // that is fine, the consumer is gone. + // The reader closed its end because it already received the output it + // wanted, so exit quietly and successfully. std::process::exit(0); } WriteOutcome::Failed(message) => Err(message), @@ -112,7 +165,7 @@ fn main() -> ExitCode } } -// Dispatches on the subcommand. argv[0] is the program name. +// Dispatches on the subcommand. fn run(args: &[String]) -> Result<(), ToolError> { let command = args.get(1).map(String::as_str); @@ -120,20 +173,24 @@ fn run(args: &[String]) -> Result<(), ToolError> { Some("sign") => run_sign(&args[2..]), Some("derive-pubkey") => run_derive_pubkey(&args[2..]), + Some("assemble-bank") => run_assemble_bank(&args[2..]), + Some("prepare-external") => run_prepare_external(&args[2..]), + Some("finalize-external") => run_finalize_external(&args[2..]), Some("--help") | Some("-h") | None => print_usage(), Some(other) => Err(format!( - "unknown subcommand '{other}', expected 'sign' or 'derive-pubkey'" + "unknown subcommand '{other}', expected 'sign', 'derive-pubkey', \ + 'assemble-bank', 'prepare-external', or 'finalize-external'" )), } } -// An explicit help request prints usage to stdout so it can be piped. The -// error path for bad args never calls this: it returns an Err that main prints -// to stderr with a non-zero exit. +// An explicit help request prints usage to stdout so it can be piped. The error +// path for bad args never calls this: it returns an Err that main prints to stderr +// with a non-zero exit. fn print_usage() -> Result<(), ToolError> { let usage = "\ -patina_key firmware-image signer +patina_key firmware-image signer (ECDSA P-256 over SHA-256) USAGE: image-signer sign --payload --key-file --out \\ @@ -142,9 +199,48 @@ USAGE: image-signer derive-pubkey --key-file - --key-file takes a path to a 32-byte seed, or '-' to read the seed - from stdin (for example: gpg --decrypt seed.gpg | image-signer ... \\ - --key-file -). The seed is never passed as a literal argument. + image-signer assemble-bank --boot --secure \\ + --nonsecure --root-key-file --out \\ + --major --minor --revision --build \\ + --security-counter [--manifest ] + + image-signer prepare-external --boot --secure \\ + --nonsecure --digest --context \\ + --major --minor --revision --build \\ + --security-counter [--digest-hex ] + + image-signer finalize-external --context --signature \\ + --pubkey --out [--sig-format raw|der|auto] \\ + [--manifest ] + + --key-file takes a path to a 32-byte P-256 private key, or '-' to read + it from stdin (for example: gpg --decrypt key.gpg | image-signer ... \\ + --key-file -). The key is never passed as a literal argument. + + --expect-pubkey takes 130 hex chars, the 65-byte uncompressed SEC1 + public key. + + assemble-bank is the BRING-UP path. It builds one flashable STM32U545 + A/B bank image from the three firmware images (ELF or raw .bin), signing + with the bring-up key (SHA-256 of the fixed bring-up phrase). It confirms + the public key equals --root-key-file (pass the BRING-UP key file, the + bring-up phrase's public key) and self-verifies the assembled bank the + way the device does. The PRODUCTION trust anchor + (crates/boot-stage/product_root_key.sec1) is a DIFFERENT key, so use the + finalize-external path below to build a production bank. + + prepare-external / finalize-external split assembly around an OFFLINE + signature, so the private key NEVER touches this tool. prepare-external + writes --digest (32 raw bytes, SHA-256 of HEADER||PAYLOAD) and --context + (a self-describing blob). The operator signs the DIGEST as a RAW ECDSA + P-256 signature over the 32-byte hash (the card signs the hash, it must + NOT re-hash), for example a YubiKey PIV slot with touch plus PIN. + finalize-external ingests --context, the external --signature (raw 64-byte + r||s or ASN.1 DER, auto-detected or forced by --sig-format), and the + pinned --pubkey (65-byte SEC1, the production trust anchor + crates/boot-stage/product_root_key.sec1). It normalizes the signature to low-s, + verifies it against --pubkey, lays out the bank, and self-verifies. A + signature that does not verify writes no artifact. Every input is validated. A bad input exits non-zero. "; @@ -174,6 +270,36 @@ fn take_value<'a> Err(format!("missing required flag '{flag}'")) } +// Pulls the value of an OPTIONAL flag out of the argument list. +// +// Three outcomes, kept distinct so a present-but-valueless flag is never +// silently treated as absent: +// +// - Ok(None): the flag is not present at all. +// - Ok(Some(value)): the flag is present and carries a value. +// - Err: the flag is present but has no following value, a user error. +fn take_optional_value<'a> +( + args: &'a [String], + flag: &str, +) + -> Result, ToolError> +{ + let mut iter = args.iter(); + while let Some(arg) = iter.next() + { + if arg == flag + { + return iter + .next() + .map(String::as_str) + .map(Some) + .ok_or_else(|| format!("flag '{flag}' needs a value")); + } + } + Ok(None) +} + // Parses an unsigned integer flag of the requested width through a common path. fn parse_u8(args: &[String], flag: &str) -> Result { @@ -217,38 +343,43 @@ fn hex_nibble(c: u8) -> Option } } -// Parses exactly 64 hex chars into a 32-byte public key. Fails closed on a -// wrong length or any non-hex char. No dependency, no panic. -fn parse_pubkey_hex(hex: &str) -> Result<[u8; 32], ToolError> +// Parses exactly 130 hex chars into a 65-byte uncompressed SEC1 public key. Fails +// closed on a wrong length or any non-hex char, and never panics. +fn parse_pubkey_hex(hex: &str) -> Result<[u8; ROOT_KEY_LEN], ToolError> { let bytes = hex.as_bytes(); - if bytes.len() != 64 + if bytes.len() != PUBKEY_HEX_LEN { return Err(format!( - "--expect-pubkey must be 64 hex chars (32 bytes), got {} chars", + "--expect-pubkey must be {PUBKEY_HEX_LEN} hex chars \ + ({ROOT_KEY_LEN} bytes, uncompressed SEC1), got {} chars", bytes.len() )); } - let mut out = [0u8; 32]; + let mut out = [0u8; ROOT_KEY_LEN]; for (i, slot) in out.iter_mut().enumerate() { - let hi = hex_nibble(bytes[i * 2]) + let hi = bytes + .get(i * 2) + .and_then(|c| hex_nibble(*c)) .ok_or_else(|| String::from("--expect-pubkey has a non-hex char"))?; - let lo = hex_nibble(bytes[i * 2 + 1]) + let lo = bytes + .get(i * 2 + 1) + .and_then(|c| hex_nibble(*c)) .ok_or_else(|| String::from("--expect-pubkey has a non-hex char"))?; *slot = (hi << 4) | lo; } Ok(out) } -// Loads the seed named by the `--key-file` value and validates it is EXACTLY 32 -// bytes. The value is either `-`, which reads the raw seed from stdin, or a -// filesystem path. -fn load_seed(key_file: &str) -> Result, ToolError> +// Loads the private key named by the `--key-file` value and validates it is exactly +// 32 bytes. The value is either `-`, which reads the raw key from stdin, or a +// filesystem path. Whether those 32 bytes are a valid scalar is decided by +// SoftwareSigner, which fails closed on a zero or out-of-range value. +fn load_key(key_file: &str) -> Result, ToolError> { - // For stdin, label the source "stdin" so a wrong-length error names the - // real origin rather than the literal '-'. For a path, the path is the - // label. + // For stdin, label the source "stdin" so a wrong-length error names the real + // origin rather than the literal '-'. For a path, the path is the label. let (raw, source) = if key_file == "-" { (read_stdin_bytes()?, "stdin") @@ -258,41 +389,41 @@ fn load_seed(key_file: &str) -> Result, ToolError> let bytes = Zeroizing::new ( fs::read(key_file) - .map_err(|e| format!("cannot read seed file '{key_file}': {e}"))?, + .map_err(|e| format!("cannot read key file '{key_file}': {e}"))?, ); (bytes, key_file) }; - seed_from_bytes(&raw, source) + key_from_bytes(&raw, source) } -// Reads stdin to end into a Zeroizing buffer. The decrypted seed can be piped in +// Reads stdin to end into a Zeroizing buffer. The decrypted key can be piped in // with no cleartext file on disk. The bytes are wiped when the buffer drops. fn read_stdin_bytes() -> Result>, ToolError> { - // Pre-size to SEED_LEN + 1 so a 32-byte seed needs no growth (no seed copy - // left in a freed allocation), while the +1 still lets an over-long input be - // read and rejected by the length check. - let mut buffer = Zeroizing::new(Vec::with_capacity(SEED_LEN + 1)); + // Pre-size to KEY_LEN + 1 so a 32-byte key needs no growth (no key copy left + // in a freed allocation), while the +1 still lets an over-long input be read + // and rejected by the length check. + let mut buffer = Zeroizing::new(Vec::with_capacity(KEY_LEN + 1)); std::io::stdin() .read_to_end(&mut buffer) - .map_err(|e| format!("cannot read seed from stdin: {e}"))?; + .map_err(|e| format!("cannot read key from stdin: {e}"))?; Ok(buffer) } -// Validates raw seed bytes are EXACTLY 32 long and copies them into a fixed -// Zeroizing array. A trailing newline or any extra byte makes the length wrong, -// so it fails closed, which is the intended behavior. The `source` label names -// the origin so the error message points the operator at the right input. -fn seed_from_bytes +// Validates raw key bytes are exactly 32 long and copies them into a fixed Zeroizing +// array. A trailing newline or any extra byte makes the length wrong, so it fails +// closed. The `source` label names the origin so the error message points the +// operator at the right input. +fn key_from_bytes ( raw: &[u8], source: &str, ) - -> Result, ToolError> + -> Result, ToolError> { let got = raw.len(); - let arr: [u8; SEED_LEN] = raw.try_into().map_err(|_| format!( - "seed from '{source}' must be exactly {SEED_LEN} bytes, got {got}" + let arr: [u8; KEY_LEN] = raw.try_into().map_err(|_| format!( + "key from '{source}' must be exactly {KEY_LEN} bytes, got {got}" ))?; Ok(Zeroizing::new(arr)) } @@ -314,20 +445,23 @@ fn run_sign(args: &[String]) -> Result<(), ToolError> let payload = fs::read(payload_path) .map_err(|e| format!("cannot read payload file '{payload_path}': {e}"))?; - let seed = load_seed(key_file)?; + let key = load_key(key_file)?; - let signer = SoftwareSigner::from_seed(&seed); + // A 32-byte file is not automatically a key: the scalar must lie in [1, n-1]. + // This fails closed on an all-zero or out-of-range value. + let signer = SoftwareSigner::from_key(&key) + .map_err(|e| format!("the key from '{key_file}' is unusable: {e}"))?; // Optional guard against signing with the wrong key file. If the operator // supplies an expected public key, it must equal the key the signer reports - // BEFORE any image is written, else the tool fails closed. - if let Ok(expected_hex) = take_value(args, "--expect-pubkey") + // before any image is written, else the tool fails closed. + if let Some(expected_hex) = take_optional_value(args, "--expect-pubkey")? { let expected = parse_pubkey_hex(expected_hex)?; if expected != signer.public_key() { return Err(String::from( - "--expect-pubkey does not match the seed's public key, \ + "--expect-pubkey does not match the key's public key, \ refusing to sign with the wrong key" )); } @@ -340,10 +474,11 @@ fn run_sign(args: &[String]) -> Result<(), ToolError> .map_err(|e| format!("cannot write output file '{out_path}': {e}"))?; // Informational status line on stderr. The image is already written, so this - // line must never decide the outcome. A broken stderr pipe is swallowed - // rather than exiting the process, because the seed and signer are still live - // here and their destructors MUST run to wipe the plaintext seed. - let status = format!( + // line must never decide the outcome. A broken stderr pipe is swallowed rather + // than exiting the process, because the key and signer are still live here and + // their destructors must run to wipe the plaintext key. + let status = format! + ( "wrote {} bytes to '{out_path}' ({} payload + 24 header + 64 signature)\n", image.len(), payload.len() @@ -363,27 +498,29 @@ fn run_derive_pubkey(args: &[String]) -> Result<(), ToolError> let key_file = take_value(args, "--key-file")?; let public = { - // The seed and the signer must drop and zeroize INSIDE this scope, - // before any stdout write. write_stdout may exit the process on a broken - // pipe, which skips destructors, so no secret may still be live past this - // point. The returned public key is not secret. - let seed = load_seed(key_file)?; - let signer = SoftwareSigner::from_seed(&seed); + // The key and the signer must drop and zeroize inside this scope, before any + // stdout write. write_stdout may exit the process on a broken pipe, which + // skips destructors, so no secret may still be live past this point. The + // returned public key is not secret. + let key = load_key(key_file)?; + let signer = SoftwareSigner::from_key(&key) + .map_err(|e| format!("the key from '{key_file}' is unusable: {e}"))?; signer.public_key() }; let mut text = String::new(); - // Hex form, one line, lowercase. - let mut hex = String::with_capacity(public.len() * 2); + // Hex form, one line, lowercase. This is the uncompressed SEC1 point, so it + // starts with the 04 tag. + let mut hex = String::with_capacity(PUBKEY_HEX_LEN); push_hex(&mut hex, &public); - text.push_str(&format!("public key (hex): {hex}\n")); + text.push_str(&format!("public key (hex, uncompressed SEC1): {hex}\n")); - // Ready-to-paste Rust array literal, four bytes per line to match the style - // already used for the pinned key in the firmware. This is a PUBLIC key, so - // the pin site decides the visibility: set it to pub or pub(crate) as fits. + // Ready-to-paste Rust array literal, eight bytes per line to match the style + // already used for the pinned key in the firmware. This is a public key, so the + // pin site decides the visibility: set it to pub or pub(crate) as fits. text.push_str("// set visibility to suit the pin site (pub or pub(crate))\n"); - text.push_str("pub const ROOT_KEY: [u8; 32] = [\n"); + text.push_str(&format!("pub const ROOT_KEY: [u8; {ROOT_KEY_LEN}] = [\n")); for row in public.chunks(8) { let mut line = String::from(" "); @@ -399,6 +536,484 @@ fn run_derive_pubkey(args: &[String]) -> Result<(), ToolError> write_stdout(&text) } +// Reads a little-endian u32 at `off` from a byte slice. Returns None if the +// slice is too short, so no read can panic. +fn read_u32_le_at(bytes: &[u8], off: usize) -> Option +{ + let arr: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?; + Some(u32::from_le_bytes(arr)) +} + +// True when `bytes` begins with the 4-byte ELF magic. +fn is_elf(bytes: &[u8]) -> bool +{ + bytes.get(..4) == Some(&[0x7f, b'E', b'L', b'F']) +} + +// Creates a fresh, exclusively-owned intermediate file in the system temp dir +// and returns its path. The name mixes the pid, a monotonic counter, and a +// process-random value from std's RandomState, and the file is created with +// create_new (O_EXCL), so an attacker cannot pre-plant a symlink at a guessable +// path and redirect the objcopy output. On the rare name collision it retries. +// The caller owns the returned path and removes it after reading. +fn create_unique_temp(name: &str) -> Result +{ + // A monotonic per-process counter so two calls in one process never collide. + static COUNTER: AtomicU64 = AtomicU64::new(0); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + + for _ in 0..64u32 + { + // RandomState is seeded from the OS at construction, so this hash is an + // unpredictable per-attempt value. + let mut hasher = + std::collections::hash_map::RandomState::new().build_hasher(); + hasher.write_u64(seq); + hasher.write_u32(std::process::id()); + let token = hasher.finish(); + + let candidate = env::temp_dir().join(format!( + "image-signer-{name}-{}-{seq}-{token:016x}.bin", + std::process::id() + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(_) => return Ok(candidate), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => + { + continue; + } + Err(e) => + { + return Err(format!( + "cannot create a temp file for the {name} objcopy output: {e}" + )); + } + } + } + Err(format!( + "cannot create a unique temp file for the {name} objcopy output" + )) +} + +// Converts an ELF at `path` to a flat binary with arm-none-eabi-objcopy and +// returns its bytes. The intermediate file is a fresh exclusively-created temp +// file, removed after it is read. +fn objcopy_to_binary(path: &str, name: &str) -> Result, ToolError> +{ + let tmp = create_unique_temp(name)?; + let tmp_str = tmp + .to_str() + .ok_or_else(|| String::from("the temp path is not valid UTF-8"))?; + + let status = Command::new("arm-none-eabi-objcopy") + .args(["-O", "binary", path, tmp_str]) + .status() + .map_err(|e| format!("cannot run arm-none-eabi-objcopy on '{path}': {e}"))?; + if !status.success() + { + return Err(format!( + "arm-none-eabi-objcopy failed on '{path}' with {status}" + )); + } + + let bytes = fs::read(&tmp) + .map_err(|e| format!("cannot read objcopy output '{tmp_str}': {e}"))?; + // The intermediate is best-effort cleanup: a failure to remove it is not a + // reason to fail the build. + let _ = fs::remove_file(&tmp); + Ok(bytes) +} + +// Validates a flat firmware binary carries an ARMv8-M vector table at its base: the +// first word is an initial MSP in SRAM and the second is a Thumb reset vector inside +// the image's own flash band. A wrong objcopy base or a mislinked ELF fails here, +// before any byte lands in the bank, which is the B1 class the packaging tool exists +// to catch. +fn check_reset_vector +( + bytes: &[u8], + origin: u32, + name: &str, +) + -> Result<(), ToolError> +{ + let msp = read_u32_le_at(bytes, 0).ok_or_else(|| + { + format!("the {name} image is too small to hold a vector table") + })?; + let reset = read_u32_le_at(bytes, 4).ok_or_else(|| + { + format!("the {name} image is too small to hold a vector table") + })?; + + if !(SRAM_LOW..SRAM_HIGH).contains(&msp) + { + return Err(format!( + "the {name} image initial MSP {msp:#010x} is not in SRAM \ + [{SRAM_LOW:#010x}, {SRAM_HIGH:#010x}), the load base is wrong \ + (expected origin {origin:#010x})" + )); + } + + let end = origin.saturating_add(bytes.len() as u32); + let reset_addr = reset & !1; + if reset & 1 == 0 || reset_addr < origin || reset_addr >= end + { + return Err(format!( + "the {name} image reset vector {reset:#010x} is not a Thumb address \ + in [{origin:#010x}, {end:#010x}), the objcopy base or link origin \ + is wrong" + )); + } + Ok(()) +} + +// Loads a firmware region as a flat binary. An ELF is converted with objcopy, a +// raw .bin is used as-is. Either way the vector table is validated against the +// link origin so a wrong placement is refused. +fn load_region +( + path: &str, + origin: u32, + name: &str, +) + -> Result, ToolError> +{ + let raw = fs::read(path) + .map_err(|e| format!("cannot read {name} file '{path}': {e}"))?; + let bytes = if is_elf(&raw) + { + objcopy_to_binary(path, name)? + } + else + { + raw + }; + check_reset_vector(&bytes, origin, name)?; + Ok(bytes) +} + +// Loads the pinned root public key file, which must be exactly ROOT_KEY_LEN +// bytes (an uncompressed SEC1 point). The bytes are public, so no zeroizing. +fn load_root_key(path: &str) -> Result<[u8; ROOT_KEY_LEN], ToolError> +{ + let raw = fs::read(path) + .map_err(|e| format!("cannot read root key file '{path}': {e}"))?; + let got = raw.len(); + raw.as_slice().try_into().map_err(|_| + { + format!( + "root key file '{path}' must be exactly {ROOT_KEY_LEN} bytes \ + (uncompressed SEC1), got {got}" + ) + }) +} + +fn run_assemble_bank(args: &[String]) -> Result<(), ToolError> +{ + let boot_path = take_value(args, "--boot")?; + let secure_path = take_value(args, "--secure")?; + let ns_path = take_value(args, "--nonsecure")?; + let root_key_path = take_value(args, "--root-key-file")?; + let out_path = take_value(args, "--out")?; + + let version = ImageVersion + { + major: parse_u8(args, "--major")?, + minor: parse_u8(args, "--minor")?, + revision: parse_u16(args, "--revision")?, + build: parse_u32(args, "--build")?, + }; + let security_counter = parse_u32(args, "--security-counter")?; + + let boot = load_region(boot_path, BOOT_ORIGIN, "boot-stage")?; + let secure = load_region(secure_path, SECURE_ORIGIN, "secure")?; + let nonsecure = load_region(ns_path, NS_ORIGIN, "nonsecure")?; + let root_key = load_root_key(root_key_path)?; + + // The bring-up scalar and the signer live only inside this scope so they drop and + // zeroize before the manifest write, which may exit on a broken pipe (skipping + // destructors). The scalar is never printed. + let bank = + { + let scalar: Zeroizing<[u8; KEY_LEN]> = + Zeroizing::new(Sha256::digest(BRINGUP_PHRASE).into()); + let signer = SoftwareSigner::from_key(&scalar) + .map_err(|e| format!("the bring-up scalar is unusable: {e}"))?; + assemble_bank( + &boot, + &secure, + &nonsecure, + version, + security_counter, + &signer, + &root_key, + ) + .map_err(|e| format!("assemble-bank failed: {e}"))? + }; + + fs::write(out_path, &bank.image) + .map_err(|e| format!("cannot write output file '{out_path}': {e}"))?; + + let manifest = build_manifest(&bank, out_path, KeyProvenance::BringUpDerived); + + // An optional manifest file. The stdout copy is authoritative for the operator, + // the file is a convenience. A present-but-valueless --manifest is a user error. + if let Some(manifest_path) = take_optional_value(args, "--manifest")? + { + fs::write(manifest_path, &manifest).map_err(|e| + { + format!("cannot write manifest file '{manifest_path}': {e}") + })?; + } + + write_stdout(&manifest) +} + +// Prepare step of the external-signature flow. Loads the three firmware images +// exactly as assemble-bank does (objcopy plus vector check), builds the digest and +// the context, writes them out, and prints what the operator must sign. No key is +// touched here, and nothing secret is produced: the digest and the images are +// public. +fn run_prepare_external(args: &[String]) -> Result<(), ToolError> +{ + let boot_path = take_value(args, "--boot")?; + let secure_path = take_value(args, "--secure")?; + let ns_path = take_value(args, "--nonsecure")?; + let digest_path = take_value(args, "--digest")?; + let context_path = take_value(args, "--context")?; + + let version = ImageVersion + { + major: parse_u8(args, "--major")?, + minor: parse_u8(args, "--minor")?, + revision: parse_u16(args, "--revision")?, + build: parse_u32(args, "--build")?, + }; + let security_counter = parse_u32(args, "--security-counter")?; + + let boot = load_region(boot_path, BOOT_ORIGIN, "boot-stage")?; + let secure = load_region(secure_path, SECURE_ORIGIN, "secure")?; + let nonsecure = load_region(ns_path, NS_ORIGIN, "nonsecure")?; + + let prepared = + prepare_external(&boot, &secure, &nonsecure, version, security_counter) + .map_err(|e| format!("prepare-external failed: {e}"))?; + + fs::write(digest_path, prepared.digest) + .map_err(|e| format!("cannot write digest file '{digest_path}': {e}"))?; + fs::write(context_path, &prepared.context) + .map_err(|e| format!("cannot write context file '{context_path}': {e}"))?; + + // An optional hex copy of the digest, one line, for a signer that wants hex. + if let Some(hex_path) = take_optional_value(args, "--digest-hex")? + { + let mut hex = String::with_capacity(DIGEST_LEN * 2); + push_hex(&mut hex, &prepared.digest); + hex.push('\n'); + fs::write(hex_path, &hex).map_err(|e| + { + format!("cannot write digest-hex file '{hex_path}': {e}") + })?; + } + + let mut hex = String::with_capacity(DIGEST_LEN * 2); + push_hex(&mut hex, &prepared.digest); + + let text = format!( + "prepare-external complete, NOTHING was signed here\n\ + ==================================================\n\ + digest file : {digest_path} ({DIGEST_LEN} raw bytes)\n\ + context file : {context_path}\n\ + digest (hex) : {hex}\n\ + payload : {} bytes (secure {} padded + NS {})\n\ + \n\ + SIGN THE DIGEST OFFLINE, then run finalize-external:\n\ + - sign the {DIGEST_LEN}-byte digest as a RAW ECDSA P-256 signature over\n\ + the hash. The card signs the hash bytes, it must NOT re-hash them.\n\ + - on a YubiKey PIV slot this is a touch plus PIN operation.\n\ + - feed the resulting signature (raw 64-byte r||s or ASN.1 DER) plus this\n\ + context and the pinned public key to finalize-external.\n", + prepared.payload_len, prepared.secure_len, prepared.ns_len + ); + write_stdout(&text) +} + +// Finalize step of the external-signature flow. Ingests the context, the offline +// signature, and the pinned public key, then normalizes to low-s, verifies, lays out +// the bank, and self-verifies inside finalize_external. A signature that does not +// verify writes no artifact. No key material is present at any point. +fn run_finalize_external(args: &[String]) -> Result<(), ToolError> +{ + let context_path = take_value(args, "--context")?; + let signature_path = take_value(args, "--signature")?; + let pubkey_path = take_value(args, "--pubkey")?; + let out_path = take_value(args, "--out")?; + + let sig_format = match take_optional_value(args, "--sig-format")? + { + None | Some("auto") => SigFormat::Auto, + Some("raw") => SigFormat::Raw, + Some("der") => SigFormat::Der, + Some(other) => + { + return Err(format!( + "--sig-format must be 'raw', 'der', or 'auto', got '{other}'" + )); + } + }; + + let context = fs::read(context_path) + .map_err(|e| format!("cannot read context file '{context_path}': {e}"))?; + let signature_bytes = fs::read(signature_path).map_err(|e| + { + format!("cannot read signature file '{signature_path}': {e}") + })?; + let pubkey = load_root_key(pubkey_path)?; + + let signature = parse_signature(&signature_bytes, sig_format) + .map_err(|e| format!("cannot parse the external signature: {e}"))?; + + let bank = finalize_external(&context, &signature, &pubkey) + .map_err(|e| format!("finalize-external failed: {e}"))?; + + fs::write(out_path, &bank.image) + .map_err(|e| format!("cannot write output file '{out_path}': {e}"))?; + + let manifest = + build_manifest(&bank, out_path, KeyProvenance::ExternalVerified); + + // An optional manifest file, same posture as assemble-bank: the stdout copy is + // authoritative, the file is a convenience. A present-but-valueless --manifest is + // a user error, never a silent no-op. + if let Some(manifest_path) = take_optional_value(args, "--manifest")? + { + fs::write(manifest_path, &manifest).map_err(|e| + { + format!("cannot write manifest file '{manifest_path}': {e}") + })?; + } + + write_stdout(&manifest) +} + +// Where the pinned public key reported in a manifest comes from, so the manifest +// attests the true fact for the path that built the bank. The two paths differ: +// assemble-bank derives the key and confirms it equals --root-key-file, +// finalize-external verifies an external signature against an arbitrary --pubkey. +enum KeyProvenance +{ + // The bring-up path: the key was derived from the bring-up phrase and + // confirmed equal to the pinned --root-key-file. + BringUpDerived, + // The production path: an external signature was verified against the pinned + // --pubkey, with no derivation and no equality check. + ExternalVerified, +} + +// Builds the human-readable manifest. +fn build_manifest +( + bank: &image_signer::AssembledBank, + out_path: &str, + provenance: KeyProvenance, +) + -> String +{ + let mut hex = String::new(); + push_hex(&mut hex, &bank.public_key); + + let (key_label, key_attestation) = match provenance + { + KeyProvenance::BringUpDerived => + ( + "bring-up public key", + " (derived from the bring-up phrase, confirmed EQUAL to --root-key-file)\n", + ), + KeyProvenance::ExternalVerified => + ( + "public key", + " (the external signature was verified against this pinned key)\n", + ), + }; + + let mut text = String::new(); + text.push_str("patina_key bank image assembled and SELF-VERIFIED\n"); + text.push_str("=================================================\n"); + text.push_str(&format!("artifact : {out_path}\n")); + text.push_str(&format!( + "artifact size : {} bytes (one physical bank)\n", + bank.image.len() + )); + text.push('\n'); + text.push_str( + "Region placement (region : bank offset -> alias address : length).\n" + ); + text.push_str( + "Each region uses the alias matching its own SECWM band:\n" + ); + text.push_str(&format!( + " {:<10} : offset {BOOT_OFFSET:#08x} -> {:#010x} {:<12} : {} bytes\n", + "boot stage", + BANK_BASE_SECURE + BOOT_OFFSET as u32, + "(secure)", + bank.boot_len + )); + text.push_str(&format!( + " {:<10} : offset {DESCRIPTOR_OFFSET:#08x} -> {:#010x} {:<12} : \ + 88 bytes (header 24 + sig 64)\n", + "descriptor", + BANK_BASE_SECURE + DESCRIPTOR_OFFSET as u32, + "(secure)" + )); + text.push_str(&format!( + " {:<10} : offset {SECURE_OFFSET:#08x} -> {:#010x} {:<12} : \ + {} bytes in the {SECURE_LEN}-byte band\n", + "secure app", + BANK_BASE_SECURE + SECURE_OFFSET as u32, + "(secure)", + bank.secure_len + )); + text.push_str(&format!( + " {:<10} : offset {NS_OFFSET:#08x} -> {:#010x} {:<12} : \ + {} bytes in the {NS_LEN}-byte band\n", + "NS app", + BANK_BASE_NS + NS_OFFSET as u32, + "(non-secure)", + bank.ns_len + )); + text.push('\n'); + text.push_str(&format!( + "secure band length : {SECURE_LEN} bytes (0x{SECURE_LEN:x}, pages 10-19)\n" + )); + text.push_str(&format!( + "NS band length : {NS_LEN} bytes (0x{NS_LEN:x}, pages 20-31)\n" + )); + text.push_str(&format!( + "signed payload length : {} bytes (secure {SECURE_LEN} + NS {})\n", + bank.payload_len, bank.ns_len + )); + text.push('\n'); + text.push_str(&format!("{key_label:<23}: {hex}\n")); + text.push_str(key_attestation); + text.push_str("self-verify : PASS (four-segment device verify accepts)\n"); + text.push('\n'); + text.push_str( + "FLASHING PROCEDURE: NOTHING may be flashed until the option bytes are\n" + ); + text.push_str( + "provisioned: SECWM1=[0,19], SECWM2=[0,19], SECBOOTADD0=0x0C004000, and\n" + ); + text.push_str( + "the target bank ERASED FIRST.\n" + ); + text +} + #[cfg(test)] mod tests { @@ -460,4 +1075,127 @@ mod tests _ => panic!("any other error kind must map to Failed"), } } + + // read_u32_le_at reads a little-endian word at a valid offset. + #[test] + fn read_u32_le_at_reads_a_valid_word() + { + let bytes = [0x78, 0x56, 0x34, 0x12, 0xAA]; + assert_eq!(read_u32_le_at(&bytes, 0), Some(0x1234_5678)); + assert_eq!(read_u32_le_at(&bytes, 1), Some(0xAA12_3456)); + } + + // The last in-bounds offset is len-4, the exact boundary that still reads. + #[test] + fn read_u32_le_at_reads_the_last_in_bounds_word() + { + let bytes = [1u8, 2, 3, 4, 5, 6, 7, 8]; + assert_eq!( + read_u32_le_at(&bytes, 4), + Some(u32::from_le_bytes([5, 6, 7, 8])) + ); + } + + // An offset that would read past the end returns None rather than panicking. + #[test] + fn read_u32_le_at_rejects_an_out_of_range_offset() + { + let four = [1u8, 2, 3, 4]; + assert_eq!(read_u32_le_at(&four, 1), None); + assert_eq!(read_u32_le_at(&four, 5), None); + let three = [1u8, 2, 3]; + assert_eq!(read_u32_le_at(&three, 0), None); + assert_eq!(read_u32_le_at(&[], 0), None); + } + + // is_elf accepts the 4-byte ELF magic, with or without a trailing byte. + #[test] + fn is_elf_detects_the_magic() + { + assert!(is_elf(&[0x7f, b'E', b'L', b'F'])); + assert!(is_elf(&[0x7f, b'E', b'L', b'F', 0x01])); + } + + // is_elf rejects non-ELF and any input shorter than the 4-byte magic. + #[test] + fn is_elf_rejects_non_elf_and_short_input() + { + assert!(!is_elf(b"\x7fELX")); + assert!(!is_elf(b"raw firmware bytes")); + assert!(!is_elf(&[0x7f, b'E', b'L'])); + assert!(!is_elf(&[])); + } + + // Builds a minimal flat image with an ARMv8-M vector table: MSP then the + // reset vector, padded to `len`. + fn vector_table(msp: u32, reset: u32, len: usize) -> Vec + { + let mut b = vec![0u8; len.max(8)]; + b[0..4].copy_from_slice(&msp.to_le_bytes()); + b[4..8].copy_from_slice(&reset.to_le_bytes()); + b + } + + #[test] + fn check_reset_vector_accepts_a_well_formed_table() + { + let origin = 0x0C01_4000; + let img = vector_table(0x2000_1000, (origin + 0x100) | 1, 0x400); + assert!(check_reset_vector(&img, origin, "secure").is_ok()); + } + + // Fewer than eight bytes cannot hold both vector words, so it is refused. + #[test] + fn check_reset_vector_rejects_a_too_small_image() + { + let tiny = [0u8; 4]; + assert!(check_reset_vector(&tiny, 0x0C01_4000, "secure").is_err()); + } + + // An initial MSP outside the SRAM window signals a wrong load base. + #[test] + fn check_reset_vector_rejects_an_msp_outside_sram() + { + let origin = 0x0C01_4000; + let below = vector_table(SRAM_LOW - 1, (origin + 0x100) | 1, 0x400); + assert!(check_reset_vector(&below, origin, "secure").is_err()); + // SRAM_HIGH is the exclusive top, so an MSP equal to it is rejected. + let top = vector_table(SRAM_HIGH, (origin + 0x100) | 1, 0x400); + assert!(check_reset_vector(&top, origin, "secure").is_err()); + } + + // A reset vector with bit0 clear is not a Thumb address. + #[test] + fn check_reset_vector_rejects_a_non_thumb_reset() + { + let origin = 0x0C01_4000; + let img = vector_table(0x2000_1000, origin + 0x100, 0x400); + assert!(check_reset_vector(&img, origin, "secure").is_err()); + } + + // A reset vector below the origin or past the image end signals a mislinked + // ELF or a wrong objcopy base. + #[test] + fn check_reset_vector_rejects_a_reset_outside_the_band() + { + let origin = 0x0C01_4000; + let below = vector_table(0x2000_1000, (origin - 0x100) | 1, 0x400); + assert!(check_reset_vector(&below, origin, "secure").is_err()); + let above = vector_table(0x2000_1000, (origin + 0x1000) | 1, 0x400); + assert!(check_reset_vector(&above, origin, "secure").is_err()); + } + + // Two calls create two distinct, actually-existing temp files, so the + // objcopy intermediate never reuses a guessable shared path. + #[test] + fn create_unique_temp_makes_distinct_existing_files() + { + let a = create_unique_temp("unit").expect("first temp"); + let b = create_unique_temp("unit").expect("second temp"); + assert!(a.exists(), "the temp file must exist after creation"); + assert!(b.exists(), "the temp file must exist after creation"); + assert_ne!(a, b, "two calls must yield distinct paths"); + let _ = fs::remove_file(&a); + let _ = fs::remove_file(&b); + } } diff --git a/tools/image-signer/tests/cli.rs b/tools/image-signer/tests/cli.rs index 9cff83f..d44228d 100644 --- a/tools/image-signer/tests/cli.rs +++ b/tools/image-signer/tests/cli.rs @@ -13,15 +13,31 @@ use std::process::Command; use std::process::Output; use std::process::Stdio; -// The all-0x01 dev seed and its pinned public key, used to prove the CLI -// produces an image the firmware's dev root key accepts. -const DEV_SEED: [u8; 32] = [1u8; 32]; -const DEV_ROOT_KEY: [u8; 32] = [ - 0x8a, 0x88, 0xe3, 0xdd, 0x74, 0x09, 0xf1, 0x95, - 0xfd, 0x52, 0xdb, 0x2d, 0x3c, 0xba, 0x5d, 0x72, - 0xca, 0x67, 0x09, 0xbf, 0x1d, 0x94, 0x12, 0x1b, - 0xf3, 0x74, 0x88, 0x01, 0xb4, 0x0f, 0x6f, 0x5c, -]; +use image_verify::ROOT_KEY_LEN; +use sha2::Digest; +use sha2::Sha256; + +// The all-0x01 dev private scalar, test only. A valid P-256 scalar (non-zero and far +// below the curve order), publicly known, which makes every fixture deterministic. +const DEV_KEY: [u8; 32] = [1u8; 32]; + +// Its public key, derived through the library rather than pinned a second time here, +// so this suite carries no constant that could drift from the firmware's. +fn dev_root_key() -> [u8; ROOT_KEY_LEN] +{ + image_signer::derive_public_key(&DEV_KEY).expect("the dev scalar is valid") +} + +// Concatenates the verified payload segments so a test can compare bytes. +fn collect_payload(verified: &image_verify::VerifiedImage<'_>) -> Vec +{ + let mut out = Vec::new(); + for piece in verified.payload_segments() + { + out.extend_from_slice(piece); + } + out +} // A unique scratch directory under the target tmp area for one test. The name // embeds the test tag so parallel tests never collide. @@ -47,7 +63,7 @@ fn run(args: &[&str]) -> Output } // Runs the binary with `input` piped to its stdin, the way an operator would -// pipe a decrypted seed (for example `gpg --decrypt ... | image-signer ...`). +// pipe a decrypted key (for example `gpg --decrypt ... | image-signer ...`). fn run_with_stdin(args: &[&str], input: &[u8]) -> Output { let mut child = bin() @@ -62,7 +78,7 @@ fn run_with_stdin(args: &[&str], input: &[u8]) -> Output .take() .expect("child stdin is piped") .write_all(input) - .expect("write seed to child stdin"); + .expect("write the key to child stdin"); child .wait_with_output() .expect("the signer binary completes") @@ -72,11 +88,11 @@ fn run_with_stdin(args: &[&str], input: &[u8]) -> Output fn sign_then_image_verify_accepts_under_dev_root_key() { let dir = scratch("sign-ok"); - let seed = dir.join("seed.bin"); + let key = dir.join("key.bin"); let payload = dir.join("fw.bin"); let out = dir.join("image.signed"); - fs::write(&seed, DEV_SEED).expect("write seed"); + fs::write(&key, DEV_KEY).expect("write key"); fs::write(&payload, b"end to end firmware payload").expect("write payload"); let output = run(&[ @@ -84,7 +100,7 @@ fn sign_then_image_verify_accepts_under_dev_root_key() "--payload", payload.to_str().expect("path"), "--key-file", - seed.to_str().expect("path"), + key.to_str().expect("path"), "--out", out.to_str().expect("path"), "--major", @@ -100,23 +116,25 @@ fn sign_then_image_verify_accepts_under_dev_root_key() ]); assert!(output.status.success(), "sign must succeed: {output:?}"); - // The CLI output must verify under the dev root key through the SAME path - // the device uses, never a separately rebuilt copy. + // The CLI output must verify under the dev root key through the same path the + // device uses, never a separately rebuilt copy. let image = fs::read(&out).expect("read signed image"); - let root = image_verify::RootKey::from_bytes(DEV_ROOT_KEY) + let root = image_verify::RootKey::from_bytes(dev_root_key()) .expect("dev root on-curve"); + let segments: [&[u8]; 1] = [&image]; let verified = - image_verify::verify_image(&image, &root).expect("device accepts"); - assert_eq!(verified.payload(), b"end to end firmware payload"); + image_verify::verify_image(&segments, &root).expect("device accepts"); + assert_eq!(collect_payload(&verified), b"end to end firmware payload"); assert_eq!(verified.security_counter(), 5); let _ = fs::remove_dir_all(&dir); } -// Lowercase hex of a 32-byte key, for the --expect-pubkey flag. -fn hex32(key: &[u8; 32]) -> String +// Lowercase hex of the 65-byte uncompressed SEC1 public key, for the +// --expect-pubkey flag. +fn hex_key(key: &[u8; ROOT_KEY_LEN]) -> String { - let mut s = String::with_capacity(64); + let mut s = String::with_capacity(ROOT_KEY_LEN * 2); for byte in key { s.push_str(&format!("{byte:02x}")); @@ -128,19 +146,19 @@ fn hex32(key: &[u8; 32]) -> String fn sign_with_a_matching_expect_pubkey_succeeds() { let dir = scratch("expect-ok"); - let seed = dir.join("seed.bin"); + let key = dir.join("key.bin"); let payload = dir.join("fw.bin"); let out = dir.join("image.signed"); - fs::write(&seed, DEV_SEED).expect("write seed"); + fs::write(&key, DEV_KEY).expect("write key"); fs::write(&payload, b"matching key payload").expect("write payload"); - let expected = hex32(&DEV_ROOT_KEY); + let expected = hex_key(&dev_root_key()); let output = run(&[ "sign", "--payload", payload.to_str().expect("path"), "--key-file", - seed.to_str().expect("path"), + key.to_str().expect("path"), "--out", out.to_str().expect("path"), "--major", @@ -166,24 +184,25 @@ fn sign_with_a_matching_expect_pubkey_succeeds() fn sign_with_a_mismatched_expect_pubkey_fails_closed() { let dir = scratch("expect-bad"); - let seed = dir.join("seed.bin"); + let key = dir.join("key.bin"); let payload = dir.join("fw.bin"); let out = dir.join("image.signed"); - fs::write(&seed, DEV_SEED).expect("write seed"); + fs::write(&key, DEV_KEY).expect("write key"); fs::write(&payload, b"wrong key payload").expect("write payload"); - // A valid 64-hex value that is NOT the dev seed's public key (all zeros is - // off-curve, so use the dev key with its first byte flipped, still 64 hex). - let mut other = DEV_ROOT_KEY; - other[0] ^= 0xFF; - let expected = hex32(&other); + // A well-formed 130-hex value that is not the dev key's public key. + let mut other = dev_root_key(); + // Flip a coordinate byte, not the 0x04 tag, so the value stays a well-formed + // 130-hex-char argument and the mismatch is what rejects it. + other[1] ^= 0xFF; + let expected = hex_key(&other); let output = run(&[ "sign", "--payload", payload.to_str().expect("path"), "--key-file", - seed.to_str().expect("path"), + key.to_str().expect("path"), "--out", out.to_str().expect("path"), "--major", @@ -214,17 +233,17 @@ fn sign_with_a_mismatched_expect_pubkey_fails_closed() fn derive_pubkey_prints_the_dev_root_key() { let dir = scratch("derive"); - let seed = dir.join("seed.bin"); - fs::write(&seed, DEV_SEED).expect("write seed"); + let key = dir.join("key.bin"); + fs::write(&key, DEV_KEY).expect("write key"); let output = - run(&["derive-pubkey", "--key-file", seed.to_str().expect("path")]); + run(&["derive-pubkey", "--key-file", key.to_str().expect("path")]); assert!(output.status.success(), "derive must succeed: {output:?}"); let stdout = String::from_utf8(output.stdout).expect("utf8"); // The hex line carries the dev root key. let mut hex = String::new(); - for byte in DEV_ROOT_KEY + for byte in dev_root_key() { hex.push_str(&format!("{byte:02x}")); } @@ -233,20 +252,20 @@ fn derive_pubkey_prints_the_dev_root_key() "derive-pubkey must print the dev root key hex, got: {stdout}" ); // The Rust array literal is present too. - assert!(stdout.contains("pub const ROOT_KEY: [u8; 32] = [")); + assert!(stdout.contains("pub const ROOT_KEY: [u8; 65] = [")); let _ = fs::remove_dir_all(&dir); } #[test] -fn a_truncated_seed_fails_closed() +fn a_truncated_key_fails_closed() { - let dir = scratch("short-seed"); - let seed = dir.join("seed.bin"); + let dir = scratch("short-key"); + let key = dir.join("key.bin"); let payload = dir.join("fw.bin"); let out = dir.join("image.signed"); - // 31 bytes, one short of a valid seed. - fs::write(&seed, [0u8; 31]).expect("write short seed"); + // 31 bytes, one short of a valid key. + fs::write(&key, [0u8; 31]).expect("write short key"); fs::write(&payload, b"x").expect("write payload"); let output = run(&[ @@ -254,7 +273,7 @@ fn a_truncated_seed_fails_closed() "--payload", payload.to_str().expect("path"), "--key-file", - seed.to_str().expect("path"), + key.to_str().expect("path"), "--out", out.to_str().expect("path"), "--major", @@ -268,8 +287,8 @@ fn a_truncated_seed_fails_closed() "--security-counter", "0", ]); - assert!(!output.status.success(), "short seed must fail"); - assert!(!out.exists(), "no image must be written on a bad seed"); + assert!(!output.status.success(), "short key must fail"); + assert!(!out.exists(), "no image must be written on a bad key"); let stderr = String::from_utf8(output.stderr).expect("utf8"); assert!(stderr.contains("32 bytes"), "clear message: {stderr}"); @@ -277,27 +296,27 @@ fn a_truncated_seed_fails_closed() } #[test] -fn a_too_long_seed_fails_closed() +fn a_too_long_key_fails_closed() { - let dir = scratch("long-seed"); - let seed = dir.join("seed.bin"); - fs::write(&seed, [0u8; 33]).expect("write long seed"); + let dir = scratch("long-key"); + let key = dir.join("key.bin"); + fs::write(&key, [0u8; 33]).expect("write long key"); let output = - run(&["derive-pubkey", "--key-file", seed.to_str().expect("path")]); - assert!(!output.status.success(), "long seed must fail"); + run(&["derive-pubkey", "--key-file", key.to_str().expect("path")]); + assert!(!output.status.success(), "long key must fail"); let _ = fs::remove_dir_all(&dir); } #[test] -fn a_missing_seed_file_fails_closed() +fn a_missing_key_file_fails_closed() { let output = - run(&["derive-pubkey", "--key-file", "/nonexistent/path/seed.bin"]); + run(&["derive-pubkey", "--key-file", "/nonexistent/path/key.bin"]); assert!(!output.status.success(), "missing file must fail"); let stderr = String::from_utf8(output.stderr).expect("utf8"); - assert!(stderr.contains("cannot read seed file"), "clear: {stderr}"); + assert!(stderr.contains("cannot read key file"), "clear: {stderr}"); } #[test] @@ -314,10 +333,10 @@ fn a_missing_required_flag_fails_closed() fn a_non_numeric_version_field_fails_closed() { let dir = scratch("bad-num"); - let seed = dir.join("seed.bin"); + let key = dir.join("key.bin"); let payload = dir.join("fw.bin"); let out = dir.join("image.signed"); - fs::write(&seed, DEV_SEED).expect("write seed"); + fs::write(&key, DEV_KEY).expect("write key"); fs::write(&payload, b"x").expect("write payload"); let output = run(&[ @@ -325,7 +344,7 @@ fn a_non_numeric_version_field_fails_closed() "--payload", payload.to_str().expect("path"), "--key-file", - seed.to_str().expect("path"), + key.to_str().expect("path"), "--out", out.to_str().expect("path"), "--major", @@ -355,14 +374,14 @@ fn an_unknown_subcommand_fails_closed() } #[test] -fn sign_reads_seed_from_stdin_and_image_verify_accepts() +fn sign_reads_key_from_stdin_and_image_verify_accepts() { let dir = scratch("sign-stdin"); let payload = dir.join("fw.bin"); let out = dir.join("image.signed"); fs::write(&payload, b"stdin piped firmware payload").expect("write payload"); - // The seed is piped to stdin, no cleartext seed file on disk. + // The key is piped to stdin, no cleartext key file on disk. let output = run_with_stdin( &[ "sign", @@ -383,30 +402,31 @@ fn sign_reads_seed_from_stdin_and_image_verify_accepts() "--security-counter", "5", ], - &DEV_SEED, + &DEV_KEY, ); assert!(output.status.success(), "stdin sign must succeed: {output:?}"); let image = fs::read(&out).expect("read signed image"); - let root = image_verify::RootKey::from_bytes(DEV_ROOT_KEY) + let root = image_verify::RootKey::from_bytes(dev_root_key()) .expect("dev root on-curve"); + let segments: [&[u8]; 1] = [&image]; let verified = - image_verify::verify_image(&image, &root).expect("device accepts"); - assert_eq!(verified.payload(), b"stdin piped firmware payload"); + image_verify::verify_image(&segments, &root).expect("device accepts"); + assert_eq!(collect_payload(&verified), b"stdin piped firmware payload"); assert_eq!(verified.security_counter(), 5); let _ = fs::remove_dir_all(&dir); } #[test] -fn derive_pubkey_reads_seed_from_stdin() +fn derive_pubkey_reads_key_from_stdin() { - let output = run_with_stdin(&["derive-pubkey", "--key-file", "-"], &DEV_SEED); + let output = run_with_stdin(&["derive-pubkey", "--key-file", "-"], &DEV_KEY); assert!(output.status.success(), "stdin derive must succeed: {output:?}"); let stdout = String::from_utf8(output.stdout).expect("utf8"); let mut hex = String::new(); - for byte in DEV_ROOT_KEY + for byte in dev_root_key() { hex.push_str(&format!("{byte:02x}")); } @@ -419,12 +439,12 @@ fn derive_pubkey_reads_seed_from_stdin() } #[test] -fn a_wrong_length_stdin_seed_fails_closed() +fn a_wrong_length_stdin_key_fails_closed() { - // 33 bytes piped in, one past a valid seed. A trailing byte (such as a + // 33 bytes piped in, one past a valid key. A trailing byte (such as a // newline) makes the length wrong, which must fail closed. let output = run_with_stdin(&["derive-pubkey", "--key-file", "-"], &[0u8; 33]); - assert!(!output.status.success(), "wrong-length stdin seed must fail"); + assert!(!output.status.success(), "wrong-length stdin key must fail"); let stderr = String::from_utf8(output.stderr).expect("utf8"); assert!(stderr.contains("32 bytes"), "clear message: {stderr}"); } @@ -433,11 +453,11 @@ fn a_wrong_length_stdin_seed_fails_closed() fn derive_pubkey_with_an_early_closing_reader_does_not_panic() { let dir = scratch("early-close"); - let seed = dir.join("seed.bin"); - fs::write(&seed, DEV_SEED).expect("write seed"); + let key = dir.join("key.bin"); + fs::write(&key, DEV_KEY).expect("write key"); let mut child = bin() - .args(["derive-pubkey", "--key-file", seed.to_str().expect("path")]) + .args(["derive-pubkey", "--key-file", key.to_str().expect("path")]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() @@ -452,14 +472,14 @@ fn derive_pubkey_with_an_early_closing_reader_does_not_panic() let mut line = String::new(); reader.read_line(&mut line).expect("read first line"); line - // reader drops HERE, closing the read end early. + // reader drops here, closing the read end early. }; let output = child.wait_with_output().expect("the signer binary completes"); // The first line carries the dev root key hex. let mut hex = String::new(); - for byte in DEV_ROOT_KEY + for byte in dev_root_key() { hex.push_str(&format!("{byte:02x}")); } @@ -477,3 +497,686 @@ fn derive_pubkey_with_an_early_closing_reader_does_not_panic() let _ = fs::remove_dir_all(&dir); } + +// 32 bytes are not automatically a P-256 key. An all-zero file is the scalar 0, +// which is outside [1, n-1], so the tool must refuse it rather than sign with it. +#[test] +fn an_all_zero_key_file_fails_closed() +{ + let dir = scratch("zero-key"); + let key = dir.join("key.bin"); + fs::write(&key, [0u8; 32]).expect("write zero key"); + + let output = + run(&["derive-pubkey", "--key-file", key.to_str().expect("path")]); + assert!(!output.status.success(), "an all-zero scalar must fail"); + let stderr = String::from_utf8(output.stderr).expect("utf8"); + assert!( + stderr.contains("not a valid P-256 private scalar"), + "the message must name the real cause: {stderr}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +// A 32-byte file at or above the curve order is out of range too, and it must not +// silently be reduced mod n. +#[test] +fn an_out_of_range_key_file_fails_closed() +{ + let dir = scratch("high-key"); + let key = dir.join("key.bin"); + let payload = dir.join("fw.bin"); + let out = dir.join("image.signed"); + fs::write(&key, [0xFFu8; 32]).expect("write out-of-range key"); + fs::write(&payload, b"x").expect("write payload"); + + let output = run(&[ + "sign", + "--payload", + payload.to_str().expect("path"), + "--key-file", + key.to_str().expect("path"), + "--out", + out.to_str().expect("path"), + "--major", + "1", + "--minor", + "0", + "--revision", + "0", + "--build", + "0", + "--security-counter", + "0", + ]); + assert!(!output.status.success(), "an out-of-range scalar must fail"); + assert!(!out.exists(), "no image on an unusable key"); + + let _ = fs::remove_dir_all(&dir); +} + +// =========================================================================== +// assemble-bank end-to-end tests. +// +// These drive the whole subcommand through the compiled binary with raw .bin inputs. +// Raw input skips the objcopy branch (is_elf gates it), so no ARM toolchain and no +// ELF fixture is needed. The happy path proves a 256K self-verified artifact plus a +// manifest is written, the failure paths prove every rejection exits non-zero and +// writes no artifact. +// =========================================================================== + +// The link origins the three firmware images are built at. They mirror the +// private constants in main.rs, which are fixed hardware addresses. +const BOOT_ORIGIN: u32 = 0x0C00_4000; +const SECURE_ORIGIN: u32 = 0x0C01_4000; +const NS_ORIGIN: u32 = 0x0802_8000; + +// The fixed bring-up phrase whose SHA-256 is the bring-up private scalar. It must +// match the phrase in main.rs and crates/boot-stage/src/mock.rs. Any drift is caught +// by the tool, which refuses to build unless the derived public key equals the +// --root-key-file value. +const BRINGUP_PHRASE: &[u8] = + b"patina_key MCU image root - BRING-UP ONLY - replace at ceremony freeze"; + +// Derives the bring-up root public key from the phrase, the value the tool confirms +// against --root-key-file. Derived rather than pinned, so this suite carries no +// 65-byte constant that could drift from the firmware's. +fn bringup_root_key() -> [u8; ROOT_KEY_LEN] +{ + let scalar: [u8; 32] = Sha256::digest(BRINGUP_PHRASE).into(); + image_signer::derive_public_key(&scalar).expect("the bring-up scalar is valid") +} + +// Builds a minimal raw firmware .bin with a valid ARMv8-M vector table: a valid +// SRAM initial MSP, then a Thumb reset vector inside the image's own band. +fn fw_bin(origin: u32, len: usize) -> Vec +{ + let mut b = vec![0xFFu8; len]; + b[0..4].copy_from_slice(&0x2000_1000u32.to_le_bytes()); + let reset = (origin + 0x100) | 1; + b[4..8].copy_from_slice(&reset.to_le_bytes()); + b +} + +// The common flag list for an assemble-bank run over four input paths. +fn assemble_args<'a> +( + boot: &'a str, + secure: &'a str, + ns: &'a str, + root_key: &'a str, + out: &'a str, +) + -> Vec<&'a str> +{ + vec![ + "assemble-bank", + "--boot", + boot, + "--secure", + secure, + "--nonsecure", + ns, + "--root-key-file", + root_key, + "--out", + out, + "--major", + "0", + "--minor", + "0", + "--revision", + "1", + "--build", + "0", + "--security-counter", + "7", + ] +} + +#[test] +fn assemble_bank_happy_path_writes_a_verified_bank_and_manifest() +{ + let dir = scratch("assemble-ok"); + let boot = dir.join("boot.bin"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let root_key = dir.join("root.sec1"); + let out = dir.join("bank.bin"); + let manifest = dir.join("manifest.txt"); + + fs::write(&boot, fw_bin(BOOT_ORIGIN, 0x400)).expect("write boot"); + fs::write(&secure, fw_bin(SECURE_ORIGIN, 0x400)).expect("write secure"); + fs::write(&ns, fw_bin(NS_ORIGIN, 0x400)).expect("write ns"); + fs::write(&root_key, bringup_root_key()).expect("write root key"); + + let mut args = assemble_args( + boot.to_str().expect("path"), + secure.to_str().expect("path"), + ns.to_str().expect("path"), + root_key.to_str().expect("path"), + out.to_str().expect("path"), + ); + args.push("--manifest"); + let manifest_str = manifest.to_str().expect("path"); + args.push(manifest_str); + + let output = run(&args); + assert!(output.status.success(), "assemble must succeed: {output:?}"); + + // A full physical-bank artifact was written. + let artifact = fs::read(&out).expect("read artifact"); + assert_eq!(artifact.len(), 262144, "the artifact is one physical bank"); + + // The manifest file and the stdout copy both carry the self-verify result and the + // inline flashing preconditions, and no longer carry the wrong single-alias / + // TZEN=0 flashing instruction. + let manifest_text = fs::read_to_string(&manifest).expect("read manifest"); + assert!( + manifest_text.contains("self-verify : PASS"), + "manifest must report the self-verify PASS: {manifest_text}" + ); + assert!( + manifest_text.contains("SECBOOTADD0=0x0C004000"), + "manifest must carry the inline flashing preconditions: {manifest_text}" + ); + assert!( + !manifest_text.contains("TZEN=0"), + "manifest must not carry the wrong TZEN=0 label: {manifest_text}" + ); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!( + stdout.contains("SELF-VERIFIED"), + "stdout must carry the manifest: {stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn assemble_bank_oversize_secure_fails_closed() +{ + let dir = scratch("assemble-oversize"); + let boot = dir.join("boot.bin"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let root_key = dir.join("root.sec1"); + let out = dir.join("bank.bin"); + + // One byte past the secure band. The vector table is still valid, so the + // rejection is the size check, not a malformed image. + fs::write(&boot, fw_bin(BOOT_ORIGIN, 0x400)).expect("write boot"); + fs::write(&secure, fw_bin(SECURE_ORIGIN, image_signer::SECURE_LEN + 1)) + .expect("write secure"); + fs::write(&ns, fw_bin(NS_ORIGIN, 0x400)).expect("write ns"); + fs::write(&root_key, bringup_root_key()).expect("write root key"); + + let args = assemble_args( + boot.to_str().expect("path"), + secure.to_str().expect("path"), + ns.to_str().expect("path"), + root_key.to_str().expect("path"), + out.to_str().expect("path"), + ); + let output = run(&args); + assert!(!output.status.success(), "an oversize secure image must fail"); + assert!(!out.exists(), "no artifact must be written on an oversize input"); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn assemble_bank_pubkey_mismatch_fails_closed() +{ + let dir = scratch("assemble-mismatch"); + let boot = dir.join("boot.bin"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let root_key = dir.join("root.sec1"); + let out = dir.join("bank.bin"); + + fs::write(&boot, fw_bin(BOOT_ORIGIN, 0x400)).expect("write boot"); + fs::write(&secure, fw_bin(SECURE_ORIGIN, 0x400)).expect("write secure"); + fs::write(&ns, fw_bin(NS_ORIGIN, 0x400)).expect("write ns"); + + // A well-formed 65-byte SEC1 point that is not the bring-up key: flip a + // coordinate byte, not the 0x04 tag, so the length stays valid and the mismatch + // is what rejects it. + let mut wrong = bringup_root_key(); + wrong[1] ^= 0xFF; + fs::write(&root_key, wrong).expect("write wrong root key"); + + let args = assemble_args( + boot.to_str().expect("path"), + secure.to_str().expect("path"), + ns.to_str().expect("path"), + root_key.to_str().expect("path"), + out.to_str().expect("path"), + ); + let output = run(&args); + assert!(!output.status.success(), "a pubkey mismatch must fail"); + assert!(!out.exists(), "no artifact must be written on a key mismatch"); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn assemble_bank_missing_input_file_fails_closed() +{ + let dir = scratch("assemble-missing"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let root_key = dir.join("root.sec1"); + let out = dir.join("bank.bin"); + let missing_boot = dir.join("does-not-exist.bin"); + + fs::write(&secure, fw_bin(SECURE_ORIGIN, 0x400)).expect("write secure"); + fs::write(&ns, fw_bin(NS_ORIGIN, 0x400)).expect("write ns"); + fs::write(&root_key, bringup_root_key()).expect("write root key"); + + let args = assemble_args( + missing_boot.to_str().expect("path"), + secure.to_str().expect("path"), + ns.to_str().expect("path"), + root_key.to_str().expect("path"), + out.to_str().expect("path"), + ); + let output = run(&args); + assert!(!output.status.success(), "a missing input file must fail"); + assert!(!out.exists(), "no artifact must be written on a missing input"); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn assemble_bank_mislinked_ns_reset_vector_fails_closed() +{ + let dir = scratch("assemble-mislinked"); + let boot = dir.join("boot.bin"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let root_key = dir.join("root.sec1"); + let out = dir.join("bank.bin"); + + fs::write(&boot, fw_bin(BOOT_ORIGIN, 0x400)).expect("write boot"); + fs::write(&secure, fw_bin(SECURE_ORIGIN, 0x400)).expect("write secure"); + + // A mislinked NS image: zero the reset vector so it is not a Thumb address + // inside the NS band. This is the wrong-objcopy-base / wrong-origin class the + // packaging tool exists to catch before any byte lands in the bank. + let mut bad_ns = fw_bin(NS_ORIGIN, 0x400); + bad_ns[4..8].copy_from_slice(&0u32.to_le_bytes()); + fs::write(&ns, bad_ns).expect("write mislinked ns"); + fs::write(&root_key, bringup_root_key()).expect("write root key"); + + let args = assemble_args( + boot.to_str().expect("path"), + secure.to_str().expect("path"), + ns.to_str().expect("path"), + root_key.to_str().expect("path"), + out.to_str().expect("path"), + ); + let output = run(&args); + assert!(!output.status.success(), "a mislinked NS image must fail"); + assert!(!out.exists(), "no artifact must be written on a mislinked NS image"); + + let _ = fs::remove_dir_all(&dir); +} + +// =========================================================================== +// External-signature flow CLI tests (prepare-external / finalize-external). +// +// They drive the whole two-step flow through the compiled binary with raw .bin +// inputs (raw input skips objcopy). A software P-256 key stands in for the YubiKey: +// the test signs the digest the binary emits, then hands the signature back to +// finalize-external. This proves the offline round trip end to end without any +// private key ever reaching the tool. +// =========================================================================== + +// Signs a 32-byte digest as a RAW ECDSA P-256 signature (prehash, no re-hash). +fn ecdsa_sign_digest(digest: &[u8], key: &[u8; 32]) -> p256::ecdsa::Signature +{ + use p256::ecdsa::SigningKey; + use p256::ecdsa::signature::hazmat::PrehashSigner; + let sk = SigningKey::from_slice(key).expect("the dev scalar is valid"); + sk.sign_prehash(digest).expect("sign the digest") +} + +// The low-s twin of a signature as raw 64 bytes. +fn low_s_raw(sig: &p256::ecdsa::Signature) -> Vec +{ + let low = sig.normalize_s(); + low.to_bytes().to_vec() +} + +// The high-s twin (n - s) of a signature as raw 64 bytes. +fn high_s_raw(sig: &p256::ecdsa::Signature) -> Vec +{ + let low = sig.normalize_s(); + let (r, s) = low.split_scalars(); + let high = p256::ecdsa::Signature::from_scalars(r, -s).expect("n - s is valid"); + high.to_bytes().to_vec() +} + +// The ASN.1 DER encoding of the low-s twin, what openssl / PIV emit by default. +fn low_s_der(sig: &p256::ecdsa::Signature) -> Vec +{ + sig.normalize_s().to_der().as_bytes().to_vec() +} + +// Runs prepare-external over three raw .bin paths and returns the digest and +// context paths. Asserts the binary succeeded and printed the operator note. +fn run_prepare(dir: &std::path::Path) -> (PathBuf, PathBuf) +{ + let boot = dir.join("boot.bin"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let digest = dir.join("digest.bin"); + let context = dir.join("context.bin"); + + fs::write(&boot, fw_bin(BOOT_ORIGIN, 0x400)).expect("write boot"); + fs::write(&secure, fw_bin(SECURE_ORIGIN, 0x400)).expect("write secure"); + fs::write(&ns, fw_bin(NS_ORIGIN, 0x400)).expect("write ns"); + + let output = run(&[ + "prepare-external", + "--boot", + boot.to_str().expect("path"), + "--secure", + secure.to_str().expect("path"), + "--nonsecure", + ns.to_str().expect("path"), + "--digest", + digest.to_str().expect("path"), + "--context", + context.to_str().expect("path"), + "--major", + "0", + "--minor", + "0", + "--revision", + "1", + "--build", + "0", + "--security-counter", + "7", + ]); + assert!(output.status.success(), "prepare must succeed: {output:?}"); + + // The digest is 32 raw bytes, and the note tells the operator to sign it raw + // without re-hashing. + let digest_bytes = fs::read(&digest).expect("read digest"); + assert_eq!(digest_bytes.len(), 32, "the digest is a 32-byte SHA-256 output"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!( + stdout.contains("RAW ECDSA P-256 signature") && stdout.contains("NOT re-hash"), + "the operator note must say sign raw, no re-hash: {stdout}" + ); + + (digest, context) +} + +// Runs finalize-external and returns the raw Output plus the bank path. +fn run_finalize +( + dir: &std::path::Path, + context: &std::path::Path, + sig_path: &std::path::Path, + pubkey: &[u8; ROOT_KEY_LEN], + sig_format: Option<&str>, +) + -> (Output, PathBuf) +{ + let pubkey_path = dir.join("pubkey.sec1"); + let out = dir.join("bank.bin"); + let manifest = dir.join("manifest.txt"); + fs::write(&pubkey_path, pubkey).expect("write pubkey"); + + let mut args = vec![ + String::from("finalize-external"), + String::from("--context"), + context.to_str().expect("path").to_string(), + String::from("--signature"), + sig_path.to_str().expect("path").to_string(), + String::from("--pubkey"), + pubkey_path.to_str().expect("path").to_string(), + String::from("--out"), + out.to_str().expect("path").to_string(), + String::from("--manifest"), + manifest.to_str().expect("path").to_string(), + ]; + if let Some(fmt) = sig_format + { + args.push(String::from("--sig-format")); + args.push(fmt.to_string()); + } + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + (run(&refs), out) +} + +#[test] +fn external_flow_low_s_round_trip_writes_a_verified_bank() +{ + let dir = scratch("ext-low-s"); + let (digest_path, context_path) = run_prepare(&dir); + + // The operator signs the digest offline. + let digest = fs::read(&digest_path).expect("read digest"); + let sig = ecdsa_sign_digest(&digest, &DEV_KEY); + let sig_path = dir.join("sig.raw"); + fs::write(&sig_path, low_s_raw(&sig)).expect("write signature"); + + let (output, out) = + run_finalize(&dir, &context_path, &sig_path, &dev_root_key(), None); + assert!(output.status.success(), "finalize must succeed: {output:?}"); + + let bank = fs::read(&out).expect("read bank"); + assert_eq!(bank.len(), 262144, "the artifact is one physical bank"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!( + stdout.contains("self-verify : PASS"), + "the manifest must report the self-verify PASS: {stdout}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn external_flow_high_s_is_normalized_into_a_verified_bank() +{ + let dir = scratch("ext-high-s"); + let (digest_path, context_path) = run_prepare(&dir); + + let digest = fs::read(&digest_path).expect("read digest"); + let sig = ecdsa_sign_digest(&digest, &DEV_KEY); + // Deliberately hand finalize the high-s encoding the device would reject. + let sig_path = dir.join("sig.raw"); + fs::write(&sig_path, high_s_raw(&sig)).expect("write high-s signature"); + + let (output, out) = + run_finalize(&dir, &context_path, &sig_path, &dev_root_key(), None); + assert!( + output.status.success(), + "finalize must normalize a high-s signature: {output:?}" + ); + assert_eq!(fs::read(&out).expect("read bank").len(), 262144); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn external_flow_accepts_a_der_signature() +{ + let dir = scratch("ext-der"); + let (digest_path, context_path) = run_prepare(&dir); + + let digest = fs::read(&digest_path).expect("read digest"); + let sig = ecdsa_sign_digest(&digest, &DEV_KEY); + // openssl / PIV emit DER by default. Auto-detect must accept it. + let sig_path = dir.join("sig.der"); + fs::write(&sig_path, low_s_der(&sig)).expect("write DER signature"); + + let (output, out) = + run_finalize(&dir, &context_path, &sig_path, &dev_root_key(), None); + assert!(output.status.success(), "finalize must accept DER: {output:?}"); + assert_eq!(fs::read(&out).expect("read bank").len(), 262144); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn external_flow_rejects_a_wrong_key_signature_and_writes_no_bank() +{ + let dir = scratch("ext-wrong-key"); + let (digest_path, context_path) = run_prepare(&dir); + + // Sign the correct digest with a different key, but pin the dev key. The + // signature is well-formed, so only the verify inside finalize catches it. + let digest = fs::read(&digest_path).expect("read digest"); + let wrong = ecdsa_sign_digest(&digest, &[9u8; 32]); + let sig_path = dir.join("sig.raw"); + fs::write(&sig_path, low_s_raw(&wrong)).expect("write signature"); + + let (output, out) = + run_finalize(&dir, &context_path, &sig_path, &dev_root_key(), None); + assert!(!output.status.success(), "a wrong-key signature must fail"); + assert!(!out.exists(), "no bank must be written on a rejected signature"); + let stderr = String::from_utf8(output.stderr).expect("utf8"); + assert!( + stderr.contains("does not verify"), + "the message must name the verify failure: {stderr}" + ); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn external_flow_rejects_a_corrupt_signature_and_writes_no_bank() +{ + let dir = scratch("ext-corrupt-sig"); + let (digest_path, context_path) = run_prepare(&dir); + + let digest = fs::read(&digest_path).expect("read digest"); + let sig = ecdsa_sign_digest(&digest, &DEV_KEY); + let mut raw = low_s_raw(&sig); + // Truncate the signature so it parses as neither raw nor DER. + raw.truncate(50); + let sig_path = dir.join("sig.raw"); + fs::write(&sig_path, &raw).expect("write corrupt signature"); + + let (output, out) = run_finalize( + &dir, + &context_path, + &sig_path, + &dev_root_key(), + Some("raw"), + ); + assert!(!output.status.success(), "a corrupt signature must fail"); + assert!(!out.exists(), "no bank must be written on a corrupt signature"); + + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn external_flow_signature_over_a_different_digest_is_rejected() +{ + let dir = scratch("ext-wrong-digest"); + let (_digest_path, context_path) = run_prepare(&dir); + + // A signature over some other message, valid under the dev key, must be rejected: + // the verify recomputes the digest from the context. + let other_digest = Sha256::digest(b"a totally different message"); + let sig = ecdsa_sign_digest(&other_digest, &DEV_KEY); + let sig_path = dir.join("sig.raw"); + fs::write(&sig_path, low_s_raw(&sig)).expect("write signature"); + + let (output, out) = + run_finalize(&dir, &context_path, &sig_path, &dev_root_key(), None); + assert!(!output.status.success(), "a wrong-digest signature must fail"); + assert!(!out.exists(), "no bank on a signature over a different digest"); + + let _ = fs::remove_dir_all(&dir); +} + +// The external finalize bank is byte-identical to the assemble-bank output for the +// same inputs and key. This cross-checks that the external and internal paths lay +// down the exact same bytes. +#[test] +fn external_flow_bank_matches_assemble_bank_byte_for_byte() +{ + let dir = scratch("ext-vs-internal"); + + // Build the external bank. prepare-external signs nothing, so the key used to sign + // the digest is the only key in play, and it must equal the pinned key. Use the + // bring-up key so assemble-bank (which signs with the bring-up key) produces the + // comparison bank under the same key. + let boot = dir.join("boot.bin"); + let secure = dir.join("secure.bin"); + let ns = dir.join("ns.bin"); + let digest_path = dir.join("digest.bin"); + let context_path = dir.join("context.bin"); + fs::write(&boot, fw_bin(BOOT_ORIGIN, 0x400)).expect("write boot"); + fs::write(&secure, fw_bin(SECURE_ORIGIN, 0x400)).expect("write secure"); + fs::write(&ns, fw_bin(NS_ORIGIN, 0x400)).expect("write ns"); + + let prep = run(&[ + "prepare-external", + "--boot", + boot.to_str().expect("path"), + "--secure", + secure.to_str().expect("path"), + "--nonsecure", + ns.to_str().expect("path"), + "--digest", + digest_path.to_str().expect("path"), + "--context", + context_path.to_str().expect("path"), + "--major", + "0", + "--minor", + "0", + "--revision", + "1", + "--build", + "0", + "--security-counter", + "7", + ]); + assert!(prep.status.success(), "prepare must succeed: {prep:?}"); + + // Sign the digest with the bring-up key (SHA-256 of the phrase), the same key + // assemble-bank uses, deterministically (RFC 6979), so the low-s signature is the + // same. + let bringup_scalar: [u8; 32] = Sha256::digest(BRINGUP_PHRASE).into(); + let digest = fs::read(&digest_path).expect("read digest"); + let sig = ecdsa_sign_digest(&digest, &bringup_scalar); + let sig_path = dir.join("sig.raw"); + fs::write(&sig_path, low_s_raw(&sig)).expect("write signature"); + + let (output, external_out) = + run_finalize(&dir, &context_path, &sig_path, &bringup_root_key(), None); + assert!(output.status.success(), "finalize must succeed: {output:?}"); + + // Build the assemble-bank comparison artifact from the same raw inputs. + let root_key = dir.join("root.sec1"); + let internal_out = dir.join("internal-bank.bin"); + fs::write(&root_key, bringup_root_key()).expect("write root key"); + let assemble = run(&assemble_args( + boot.to_str().expect("path"), + secure.to_str().expect("path"), + ns.to_str().expect("path"), + root_key.to_str().expect("path"), + internal_out.to_str().expect("path"), + )); + assert!(assemble.status.success(), "assemble must succeed: {assemble:?}"); + + let external_bank = fs::read(&external_out).expect("read external bank"); + let internal_bank = fs::read(&internal_out).expect("read internal bank"); + assert_eq!( + external_bank, internal_bank, + "the external and internal paths must produce identical banks" + ); + + let _ = fs::remove_dir_all(&dir); +} diff --git a/tools/image-signer/tests/end_to_end.rs b/tools/image-signer/tests/end_to_end.rs index fad11a5..3ca9659 100644 --- a/tools/image-signer/tests/end_to_end.rs +++ b/tools/image-signer/tests/end_to_end.rs @@ -1,9 +1,10 @@ //! End-to-end test: the signer's output drives the real update machine. //! -//! A payload is signed with the all-0x01 dev seed, then the SAME bytes are +//! A payload is signed with the all-0x01 dev P-256 scalar, then the same bytes are //! verified by `image_verify::verify_image` and fed through the `fw-update` //! dual-bank machine, which pins the dev root key. +use fw_update::DEV_ROOT_KEY_TEST_ONLY; use fw_update::MockFlash; use fw_update::MockSeCounter; use fw_update::SE_COUNTER_ORIGIN; @@ -14,10 +15,11 @@ use image_signer::SoftwareSigner; use image_signer::build_signed_image; use image_verify::ImageVersion; use image_verify::RootKey; +use image_verify::VerifiedImage; use image_verify::verify_image; -// The all-0x01 dev seed. Its public key equals fw_update::DEV_ROOT_KEY. -const DEV_SEED: [u8; 32] = [1u8; 32]; +// The all-0x01 dev private scalar. Its public key equals DEV_ROOT_KEY_TEST_ONLY. +const DEV_KEY: [u8; 32] = [1u8; 32]; fn version() -> ImageVersion { @@ -30,10 +32,21 @@ fn version() -> ImageVersion } } +// Concatenates the verified payload segments so a test can compare bytes. +fn collect_payload(verified: &VerifiedImage<'_>) -> Vec +{ + let mut out = Vec::new(); + for piece in verified.payload_segments() + { + out.extend_from_slice(piece); + } + out +} + #[test] fn signed_image_is_accepted_by_verifier_and_update_machine() { - let signer = SoftwareSigner::from_seed(&DEV_SEED); + let signer = SoftwareSigner::from_key(&DEV_KEY).expect("the dev scalar is valid"); let payload = b"end to end firmware payload"; let security_counter = 5u32; @@ -42,29 +55,31 @@ fn signed_image_is_accepted_by_verifier_and_update_machine() build_signed_image(payload, version(), security_counter, &signer) .expect("signing must succeed"); - // 2. The signer's public key must be the dev root key the firmware pins. - assert_eq!(signer.public_key(), fw_update::DEV_ROOT_KEY); + // 2. The signer's public key must be the dev root key the firmware pins. The tool + // and the firmware agree on the encoding (uncompressed SEC1, 65 bytes) and on + // the value. + assert_eq!(signer.public_key(), DEV_ROOT_KEY_TEST_ONLY); // 3. image-verify accepts the exact bytes under the dev root key. - let root = RootKey::from_bytes(fw_update::DEV_ROOT_KEY) + let root = RootKey::from_bytes(DEV_ROOT_KEY_TEST_ONLY) .expect("dev root on-curve"); + let segments: [&[u8]; 1] = [&image]; let verified = - verify_image(&image, &root).expect("verify_image must accept"); - assert_eq!(verified.payload(), payload); + verify_image(&segments, &root).expect("verify_image must accept"); + assert_eq!(collect_payload(&verified), payload); assert_eq!(verified.security_counter(), security_counter); - // 4. The dual-bank update machine consumes the SAME bytes through its mock - // seam, all the way to a committed and confirmed swap. + // 4. The dual-bank update machine consumes the same bytes through its mock seam, + // all the way to a committed and confirmed swap. let flash = MockFlash::new(0); let se = MockSeCounter::new(SE_COUNTER_ORIGIN); let mut up = Updater::new(&root, flash, se); up.begin(image.len()).expect("begin"); up.receive_chunk(0, &image).expect("receive"); - // The machine streamed the exact tool output into its inactive bank, then - // ran verify off that same bank. Reaching PendingCommit is the public proof - // the machine accepted the signer's real bytes (it is non-vacuous: the - // wrong-key case below reaches an Err on this same call). + // The machine streamed the exact tool output into its inactive bank, then ran + // verify off that same bank. Reaching PendingCommit proves the machine accepted + // the signer's real bytes up.verify_and_accept().expect("machine accepts the signed image"); assert_eq!(up.state(), UpdateState::PendingCommit); @@ -78,14 +93,14 @@ fn signed_image_is_accepted_by_verifier_and_update_machine() #[test] fn an_image_signed_by_a_wrong_key_is_rejected_by_the_machine() { - // A non-dev seed yields a different public key, so the machine pinning the - // dev root key must reject the tool's output. This pins that the e2e accept - // above is real, not a path that accepts anything. - let signer = SoftwareSigner::from_seed(&[2u8; 32]); + // A non-dev scalar yields a different public key, so the machine pinning the dev + // root key must reject the tool's output. This pins that the e2e accept above is + // real, not a path that accepts anything. + let signer = SoftwareSigner::from_key(&[2u8; 32]).expect("valid scalar"); let image = build_signed_image(b"evil", version(), 5, &signer) - .expect("signing succeeds, the tool self-checks under its OWN key"); + .expect("signing succeeds, the tool self-checks under its own key"); - let root = RootKey::from_bytes(fw_update::DEV_ROOT_KEY) + let root = RootKey::from_bytes(DEV_ROOT_KEY_TEST_ONLY) .expect("dev root on-curve"); let flash = MockFlash::new(0); let se = MockSeCounter::new(SE_COUNTER_ORIGIN); From 263b5be8c0f8933cde1797c5c8190a16a040411e Mon Sep 17 00:00:00 2001 From: 0xEthamin Date: Tue, 11 Aug 2026 23:27:00 +0200 Subject: [PATCH 2/2] firmware: drop on-MCU attestation, single-source NSC window The se-session CI branch stopped linking: 69 KB of on-MCU X.509 chain verification no longer fit the secure flash band. - Remove on-MCU TROPIC01 X.509 chain verification (~69 KB). Attestation is a provisioning-time operation, and the shipped firmware delegates trust to the pairing key instead. Remove the firmware path, the NSC veneer, the call sites, and the attestation dependency of the se-session feature. The Ed25519 KAT stays because it validates the SE signing path used later. - Move the NSC window to the last 512 B of the secure flash band, recovering 7680 B of code space and shrinking the Non-Secure-Callable surface sixteenfold. - Introduce mcu-layout as the single source of the NSC window address. Platform, the generated linker script and its ASSERTs all derive from it, replacing two hand-synced copies that nothing compared to each other. - Cut two exhaustive property tests from four levels of nesting to one, cognitive complexity 34 and 22 down to 1. Same input space and order, census unchanged at 456 interleavings and 444 cuts over 38 indices. The space pins are now a literal count plus a distinctness check, so they can actually fail. - Update platform MPU/SAU mappings, CI, and operator docs to match the new layout and remove obsolete attestation claims. --- .github/workflows/ci.yml | 2 +- Cargo.lock | 8 + Cargo.toml | 1 + README.md | 4 +- crates/boot-stage/src/tests.rs | 191 ++++++---- crates/mcu-flash/src/power_fault_tests.rs | 142 +++++-- crates/mcu-flash/src/regs.rs | 6 +- crates/mcu-layout/Cargo.toml | 15 + crates/mcu-layout/src/lib.rs | 82 ++++ crates/nonsecure/src/main.rs | 91 +---- crates/platform/Cargo.toml | 1 + crates/platform/src/map.rs | 32 +- crates/secure/Cargo.toml | 15 +- crates/secure/build.rs | 59 ++- crates/secure/csrc/secure_nsc.c | 13 - crates/secure/memory.x | 25 +- crates/secure/sgstubs.x | 4 +- crates/secure/src/main.rs | 6 - crates/secure/src/se_crypto.rs | 444 ---------------------- crates/secure/src/se_persist.rs | 7 +- crates/secure/src/se_readonly.rs | 6 +- crates/secure/src/se_session.rs | 28 +- crates/secure/src/se_smoke.rs | 15 - docs/bench-runner.md | 17 +- scripts/ab-bench.sh | 17 +- scripts/ci-local.sh | 2 +- 26 files changed, 487 insertions(+), 746 deletions(-) create mode 100644 crates/mcu-layout/Cargo.toml create mode 100644 crates/mcu-layout/src/lib.rs delete mode 100644 crates/secure/src/se_crypto.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ea49f..9dd1ab6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: if: matrix.feature == 'default' run: | set -euo pipefail - for c in platform mcu-spi mcu-flash image-verify fw-update tropic01-driver + for c in mcu-layout platform mcu-arch mcu-spi mcu-flash image-verify fw-update tropic01-driver do cargo clippy -p "$c" --locked --target thumbv8m.main-none-eabihf -- -D warnings done diff --git a/Cargo.lock b/Cargo.lock index e64ec40..b7d367a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,6 +458,10 @@ dependencies = [ "p256", ] +[[package]] +name = "mcu-layout" +version = "0.0.1" + [[package]] name = "mcu-spi" version = "0.0.1" @@ -538,6 +542,9 @@ checksum = "a513e167849a384b7f9b746e517604398518590a9142f4846a32e3c2a4de7b11" [[package]] name = "platform" version = "0.0.1" +dependencies = [ + "mcu-layout", +] [[package]] name = "polyval" @@ -640,6 +647,7 @@ dependencies = [ "cortex-m-rt", "ed25519-dalek", "mcu-arch", + "mcu-layout", "mcu-spi", "panic-halt", "platform", diff --git a/Cargo.toml b/Cargo.toml index 2d104e9..8362388 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/tropic01-driver", + "crates/mcu-layout", "crates/platform", "crates/mcu-arch", "crates/mcu-spi", diff --git a/README.md b/README.md index 1ad5396..d30db00 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ The platform foundation is built and proven on real silicon. The remaining work - **TrustZone split boot** end to end: clock init, the SAU / GTZC / secure-MPU partition, and the secure-to-non-secure `BXNS` hand-off across the CMSE NSC veneer bridge. The two-image build boots secure-first, then hands off to the non-secure world. - **SE driver over SPI1** from the secure world: L1 transport, L2 framing, and `Get_Info` (chip mode, RISC-V and SPECT firmware versions). - **Noise KK1 secure channel**: the full handshake on the factory pairing slot, session-key derivation, an AES-256-GCM encrypted L3 `Ping` round trip, and the chip-acknowledged session teardown. -- **Attestation**: the complete four-certificate chain verified up to the pinned Tropic Square production root, the verified chip key feeding the session open. -- **Cryptography under session**: the chip TRNG, Ed25519 key generation and signing verified end to end (the SE signs, the MCU verifies strict), ECDSA P-256 generation and signing (the signature verified cryptographically on the host), imported-key round trips (Ed25519 seed import with an on-chip pubkey matching the RFC 8032 vector), monotonic counters, and the MAC-and-Destroy PIN primitive. +- **Chip certificate and STPUB**: the secure world reads the TROPIC01 certificate store and extracts the chip static public key, which the Noise KK1 handshake then binds. The firmware does not verify the X.509 chain and pins no Tropic Square root. Chain verification up to that root is a provisioning-line operation, run once by the host tool through the driver's `attestation` feature. Both firmware images take the driver with `default-features = false`, so no chain verifier links into either one. +- **Cryptography under session**: an Ed25519 seed imported through `ECC_Key_Store` (the public key the chip derives matches the RFC 8032 test vector, the chip signs a fixed message, and `ed25519-dalek` on the MCU verifies the signature), a sign correctly rejected after the slot is erased, on-chip ECDSA P-256 key generation and signing (the signature verified cryptographically on the host from the exported public key and digest), monotonic counters, and the MAC-and-Destroy PIN primitive. Ed25519 keys are imported, never generated on the chip, and no firmware path draws from the chip TRNG yet. - **Safe reads and reversible state**: pairing-slot reads, configuration-object reads, user-memory read and erase, chip identity, and the resettable persistent state (counters, MAC-and-Destroy slots, ECC slots). - **SE firmware update** 1.0.0 to 2.0.0, exercised once on the bench. - **Secure-to-non-secure return channel**: a pinned shared non-secure output buffer plus a dedicated secure-MPU region (read-write, execute-never) so a veneer can return bytes to the non-secure world without ever accepting a non-secure pointer. diff --git a/crates/boot-stage/src/tests.rs b/crates/boot-stage/src/tests.rs index a554288..5e89bf5 100644 --- a/crates/boot-stage/src/tests.rs +++ b/crates/boot-stage/src/tests.rs @@ -312,80 +312,139 @@ fn bad_partition_wedges_before_trusting_isolation() BootOutcome::Wedge(WedgeReason::Unreadable)); } -fn health_cases() -> [ImageHealth; 4] +// The four axes of the decision input space. +const RUNNING_CASES: [BankId; 2] = [BankId::Bank1, BankId::Bank2]; + +const PENDING_CASES: [PendingFlag; 3] = +[ + PendingFlag::None, + PendingFlag::Armed(BankId::Bank1), + PendingFlag::Armed(BankId::Bank2), +]; + +const NVCNT_CASES: [u32; 3] = [0, 5, 10]; + +const HEALTH_CASES: [ImageHealth; 4] = +[ + ImageHealth::Rejected, + ImageHealth::Verified { security_counter: 0 }, + ImageHealth::Verified { security_counter: 5 }, + ImageHealth::Verified { security_counter: 10 }, +]; + +/// One point of the decision input space. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DecisionInput +{ + running: BankId, + pending: PendingFlag, + nvcnt: u32, + health: ImageHealth, +} + +/// Enumerates the cartesian product of the four axes as one flat iterator. +fn decision_inputs() -> impl Iterator +{ + RUNNING_CASES + .into_iter() + .flat_map(|running| + { + PENDING_CASES.into_iter().map(move |pending| (running, pending)) + }) + .flat_map(|(running, pending)| + { + NVCNT_CASES + .into_iter() + .map(move |nvcnt| (running, pending, nvcnt)) + }) + .flat_map(|(running, pending, nvcnt)| + { + HEALTH_CASES.into_iter().map(move |health| DecisionInput + { + running, + pending, + nvcnt, + health, + }) + }) +} + +/// A Revert only ever follows the swap-applied case with an unhealthy or +/// rolled-back new image. +fn assert_revert_is_justified(input: DecisionInput) { - [ - ImageHealth::Rejected, - ImageHealth::Verified { security_counter: 0 }, - ImageHealth::Verified { security_counter: 5 }, - ImageHealth::Verified { security_counter: 10 }, - ] + let applied = matches!(input.pending, PendingFlag::Armed(t) if t == input.running); + assert!(applied, "revert only when the swap applied"); + let bad = match input.health + { + ImageHealth::Rejected => true, + ImageHealth::Verified { security_counter } => + { + security_counter < input.nvcnt + } + }; + assert!(bad, "revert only on a bad or rolled-back image"); } -#[test] -fn decision_never_reverts_and_bumps_together() +/// A bump only ever advances toward a Verified, non-rolled-back counter. +fn assert_bump_is_justified(input: DecisionInput, plan: BootPlan) { - // The load-bearing anti-brick property: over the whole input space, a single - // decision is either a Revert (never a bump) or a Boot whose bump, when - // present, matches a Verified, non-rolled-back image. A Revert never carries a - // plan, so the NVCNT can never rise on the same decision that reverts. - for running in [BankId::Bank1, BankId::Bank2] + let Some(v) = plan.advance_nvcnt + else + { + return; + }; + match input.health { - for pending in [ - PendingFlag::None, - PendingFlag::Armed(BankId::Bank1), - PendingFlag::Armed(BankId::Bank2), - ] + ImageHealth::Verified { security_counter } => { - for nvcnt in [0u32, 5, 10] - { - for health in health_cases() - { - let decision = decide(running, pending, nvcnt, health); - match decision - { - BootDecision::Revert => - { - // A revert only ever follows the swap-applied case with - // an unhealthy or rolled-back new image. - let applied = matches!(pending, PendingFlag::Armed(t) if t == running); - assert!(applied, "revert only when the swap applied"); - let bad = match health - { - ImageHealth::Rejected => true, - ImageHealth::Verified { security_counter } => - { - security_counter < nvcnt - } - }; - assert!(bad, "revert only on a bad or rolled-back image"); - } - BootDecision::Boot(plan) => - { - if let Some(v) = plan.advance_nvcnt - { - // A bump only ever advances toward a Verified, - // non-rolled-back counter. - match health - { - ImageHealth::Verified { security_counter } => - { - assert_eq!(v, security_counter); - assert!(security_counter >= nvcnt); - } - ImageHealth::Rejected => - { - panic!("bumped on a rejected image"); - } - } - } - } - BootDecision::Wedge(_) => {} - } - } - } + assert_eq!(v, security_counter); + assert!(security_counter >= input.nvcnt); } + ImageHealth::Rejected => + { + panic!("bumped on a rejected image"); + } + } +} + +/// Asserts the enumerated inputs are pairwise distinct. +/// +/// A count pin alone still passes when an axis is rewritten to repeat one value, +/// which holds the total while dropping the cases the property constrains, such +/// as a Rejected image or a rolled-back counter. +fn assert_inputs_are_distinct(inputs: &[DecisionInput]) +{ + for (i, a) in inputs.iter().enumerate() + { + for b in inputs.iter().skip(i + 1) + { + assert_ne!(a, b, "the decision axes must enumerate distinct inputs"); + } + } +} + +/// Checks one decision against the never-revert-and-bump property. +fn assert_decision_is_safe(input: DecisionInput) +{ + match decide(input.running, input.pending, input.nvcnt, input.health) + { + BootDecision::Revert => assert_revert_is_justified(input), + BootDecision::Boot(plan) => assert_bump_is_justified(input, plan), + BootDecision::Wedge(_) => {} + } +} + +#[test] +fn decision_never_reverts_and_bumps_together() +{ + let inputs: std::vec::Vec = decision_inputs().collect(); + for input in &inputs + { + assert_decision_is_safe(*input); } + assert_eq!(inputs.len(), 72, "the decision space is 72 decisions"); + assert_inputs_are_distinct(&inputs); } #[test] diff --git a/crates/mcu-flash/src/power_fault_tests.rs b/crates/mcu-flash/src/power_fault_tests.rs index f13e372..6fe9630 100644 --- a/crates/mcu-flash/src/power_fault_tests.rs +++ b/crates/mcu-flash/src/power_fault_tests.rs @@ -708,6 +708,91 @@ const CUT_MODES: [CutMode; 3] = const RESET_OUTCOMES: [bool; 2] = [true, false]; const HEALTHS: [Health; 2] = [Health::Confirm, Health::Revert]; +/// One census point: a cut index, the mode it fires in, and the reset and health +/// branch the flow takes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CensusCase +{ + index: u32, + mode: CutMode, + reset_applied: bool, + health: Health, +} + +// Enumerates the census input space as one flat iterator: every cut mode, both +// option-load-at-reset outcomes, both health branches, and every persistent-op +// index below `n`. The index varies fastest, matching the flow order the census +// walks. +fn census_cases(n: u32) -> impl Iterator +{ + CUT_MODES + .into_iter() + .flat_map(|mode| + { + RESET_OUTCOMES + .into_iter() + .map(move |reset_applied| (mode, reset_applied)) + }) + .flat_map(|(mode, reset_applied)| + { + HEALTHS + .into_iter() + .map(move |health| (mode, reset_applied, health)) + }) + .flat_map(move |(mode, reset_applied, health)| + { + (0..n).map(move |index| CensusCase + { + index, + mode, + reset_applied, + health, + }) + }) +} + +// Marks the index whose cut fired and returns how many cuts to add to the tally. +fn record_fired(fired_seen: &mut [bool], index: u32, fired: bool) -> u32 +{ + if !fired + { + return 0; + } + if let Some(slot) = fired_seen.get_mut(index as usize) + { + *slot = true; + } + 1 +} + +// Asserts the enumerated census cases are pairwise distinct. A count pin alone +// still passes when an axis is rewritten to repeat one value, which holds the +// total while dropping the branch that axis exists to cover. +fn assert_cases_are_distinct(cases: &[CensusCase]) +{ + for (i, a) in cases.iter().enumerate() + { + for b in cases.iter().skip(i + 1) + { + assert_ne!(a, b, "the census axes must enumerate distinct cases"); + } + } +} + +// Asserts every persistent-op index fired at least once across the census. This +// is the check that proves the cut spans the whole flow, including the post-reset +// confirm and revert mutations. +fn assert_every_index_fired(fired_seen: &[bool]) +{ + for (idx, seen) in fired_seen.iter().enumerate() + { + assert!( + *seen, + "persistent op index {idx} never fired, the cut span has a gap" + ); + } +} + #[test] fn exhaustive_power_fault_interleavings_hold_the_invariant() { @@ -726,53 +811,32 @@ fn exhaustive_power_fault_interleavings_hold_the_invariant() run_flow(&root, &old_image, &new_image, None, true, Health::Revert).ops; let n = confirm_len.max(revert_len); assert!(n > ERASE_OPS, "the flow must reach past the inactive-bank erase"); + assert_eq!(n, 38, "the flow length changed, re-review the census"); + let cases: Vec = census_cases(n).collect(); + let total = cases.len() as u32; let mut fired_seen = vec![false; n as usize]; - let mut total = 0u32; let mut fired_count = 0u32; - for mode in CUT_MODES - { - for reset_applied in RESET_OUTCOMES - { - for health in HEALTHS - { - for k in 0..n - { - let out = run_flow( - &root, - &old_image, - &new_image, - Some((k, mode)), - reset_applied, - health, - ); - assert_invariants(&out.shared, old_pl, new_pl, &root, health); - total += 1; - if out.fired - { - fired_count += 1; - if let Some(slot) = fired_seen.get_mut(k as usize) - { - *slot = true; - } - } - } - } - } - } - - // Every persistent-op index must have fired at least once across the census. - // This is the assertion that proves the cut spans the whole flow, including - // the post-reset confirm and revert mutations. - for (idx, seen) in fired_seen.iter().enumerate() + for case in cases.iter().copied() { - assert!( - *seen, - "persistent op index {idx} never fired, the cut span has a gap" + let out = run_flow( + &root, + &old_image, + &new_image, + Some((case.index, case.mode)), + case.reset_applied, + case.health, ); + assert_invariants(&out.shared, old_pl, new_pl, &root, case.health); + fired_count += record_fired(&mut fired_seen, case.index, out.fired); } + assert_eq!(total, 12 * n, "every axis combination must be exercised"); + assert_cases_are_distinct(&cases); + + assert_every_index_fired(&fired_seen); + std::eprintln!( "register-level power-fault harness: {total} interleavings exercised, \ {fired_count} cuts fired, every one of {n} persistent-op indices fired \ diff --git a/crates/mcu-flash/src/regs.rs b/crates/mcu-flash/src/regs.rs index 62e2cd7..6dc95ae 100644 --- a/crates/mcu-flash/src/regs.rs +++ b/crates/mcu-flash/src/regs.rs @@ -442,8 +442,10 @@ pub(crate) const fn inactive_phys_bank(swap: bool) -> PhysBank // pages 0-1 (16 KB) secure boot metadata (physical Bank 1 only) // pages 2-8 (56 KB) secure boot stage, immutable, base = SECBOOTADD0 // page 9 (8 KB) secure image descriptor: header [0:24], signature [24:88] -// pages 10-18 (72 KB) secure secure app -// page 19 (8 KB) secure NSC veneer (.gnu.sgstubs), 0x0C026000 +// pages 10-19 (80 KB) secure secure app, CMSE veneers (.gnu.sgstubs) pinned +// in the top of page 19. The mcu-layout crate owns that +// window's address, this table deliberately does not +// restate it // pages 20-31 (96 KB) non-secure non-secure app // // The signed image file stays contiguous HEADER || PAYLOAD || SIGNATURE. The updater diff --git a/crates/mcu-layout/Cargo.toml b/crates/mcu-layout/Cargo.toml new file mode 100644 index 0000000..8afe42a --- /dev/null +++ b/crates/mcu-layout/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mcu-layout" +edition.workspace = true +version.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +license.workspace = true +publish = false +description = "STM32U545 memory-layout addresses shared by more than one crate or build script." + +[dependencies] + +[lints] +workspace = true diff --git a/crates/mcu-layout/src/lib.rs b/crates/mcu-layout/src/lib.rs new file mode 100644 index 0000000..e6153aa --- /dev/null +++ b/crates/mcu-layout/src/lib.rs @@ -0,0 +1,82 @@ +//! The Non-Secure-Callable veneer window. +//! +//! Source anchors: RM0456 (memory map, sec 7.5.8 identical-per-bank layout) and +//! the Armv8-M Architecture Reference Manual (SAU region encoding, AN5347 for +//! the secure-gateway model). + +#![cfg_attr(not(test), no_std)] + +// Non-Secure-Callable veneer window. +// +// The CMSE secure-gateway veneers (`.gnu.sgstubs`) sit at the TOP of the secure +// app band, pages 10-19 at 0x0C01_4000..0x0C02_7FFF. Two consumers read the +// window from here: +// - crates/secure/build.rs, as the linker `--section-start=.gnu.sgstubs=` +// address plus the generated bound assertions, +// - crates/platform/src/map.rs, as SAU region 0 (the only NSC region). + +/// Length of the Non-Secure-Callable veneer window, in bytes. +/// +/// 512 bytes holds 64 secure gateways of 8 bytes each. +pub const NSC_VENEER_LEN: u32 = 512; + +/// First byte after the secure app band: the top of page 19 plus one. +/// RM0456 memory map. +const SECURE_APP_BAND_END: u32 = 0x0C02_8000; + +/// Base of the Non-Secure-Callable veneer window. +/// +/// 32-byte aligned, as the SAU `RBAR` encoding fixes the low 5 bits to zero. +pub const NSC_VENEER_BASE: u32 = SECURE_APP_BAND_END - NSC_VENEER_LEN; + +/// Inclusive top byte of the Non-Secure-Callable veneer window. +/// +/// The SAU `RLAR` encoding reads the low 5 bits as one, so a limit is always the +/// inclusive top of a 32-byte unit. +pub const NSC_VENEER_LIMIT: u32 = SECURE_APP_BAND_END - 1; + +#[cfg(test)] +mod tests +{ + use super::*; + + /// One Armv8-M secure gateway, in bytes: the SG instruction plus the branch. + const GATEWAY_BYTES: u32 = 8; + /// `cmse_nonsecure_entry` functions the C shim declares in total: 4 + /// unconditional, 3 under se-session, 1 under se-fw-update. The largest + /// buildable configuration emits 7 of them. The bound uses the declared + /// total. + const WORST_CASE_GATEWAYS: u32 = 8; + + #[test] + fn nsc_window_is_pinned() + { + // The pinned values, in one place. Every other site derives them. + assert_eq!(NSC_VENEER_BASE, 0x0C02_7E00); + assert_eq!(NSC_VENEER_LIMIT, 0x0C02_7FFF); + assert_eq!(NSC_VENEER_LEN, 512); + } + + #[test] + fn nsc_window_matches_the_sau_granule() + { + // SAU RBAR fixes the low 5 bits of a base to zero, RLAR reads the low 5 + // bits of a limit as one. + assert_eq!(NSC_VENEER_BASE & 0x1F, 0); + assert_eq!(NSC_VENEER_LIMIT & 0x1F, 0x1F); + assert_eq!(NSC_VENEER_LIMIT - NSC_VENEER_BASE + 1, NSC_VENEER_LEN); + } + + #[test] + fn nsc_window_holds_the_worst_case_gateway_count() + { + // The linker also asserts this on the real section size at every build. + // This is the source-level bound. + let worst_case = WORST_CASE_GATEWAYS * GATEWAY_BYTES; + assert! + ( + NSC_VENEER_LEN >= worst_case, + "the NSC window must hold every gateway the build can emit" + ); + } +} diff --git a/crates/nonsecure/src/main.rs b/crates/nonsecure/src/main.rs index 453c98a..3b32412 100644 --- a/crates/nonsecure/src/main.rs +++ b/crates/nonsecure/src/main.rs @@ -59,15 +59,6 @@ mod firmware fn patinakey_nsc_se_session_ping() -> u32; } - // The crypto + attestation veneer rides the SAME se-session feature: it is - // only emitted secure-side under that feature. - #[cfg(feature = "se-session")] - #[allow(unsafe_code)] - unsafe extern "C" - { - fn patinakey_nsc_se_crypto() -> u32; - } - // The persistent-but-reversible state veneer rides the SAME se-session // feature: it is only emitted secure-side under that feature. #[cfg(feature = "se-session")] @@ -163,46 +154,6 @@ mod firmware } } - // CROSS-CRATE COUPLING: the crypto status-word bit layout below MUST match - // the encoding produced on the secure side (crates/secure/src/se_crypto.rs). - // The two crates do not share a type, so it is duplicated by hand and the two - // copies must stay in sync. - - /// Crypto word bit set when the secure side reports the bring-up failed. Bits - /// 15..8 then carry the step, bits 7..0 the error code. RESERVED non-SeError - /// low-byte codes: 0xF1 EdDSA verify reject, 0xF2 random sanity, 0xF3 ECDSA - /// shape, 0xF4 pubkey length. - #[cfg(feature = "se-session")] - const SCR_ERR: u32 = 1 << 31; - /// Crypto word bit set when the bring-up succeeded. The low byte carries the - /// OK marker. - #[cfg(feature = "se-session")] - const SCR_OK: u32 = 1 << 8; - - /// Decodes a crypto failing-step code into a static label. Steps mirror - /// se_crypto.rs: 1 attestation, 2 open-session, 3 random, 4 pre-clean, 5 - /// ed25519-generate, 6 ed25519-pubkey, 7 eddsa-sign, 8 eddsa-verify, 9 - /// ed25519-erase, 10 ecdsa-p256, 11 session-abort. - #[cfg(feature = "se-session")] - fn crypto_step(step: u32) -> &'static str - { - match step - { - 1 => "attestation", - 2 => "open-session", - 3 => "random", - 4 => "pre-clean", - 5 => "ed25519-generate", - 6 => "ed25519-pubkey", - 7 => "eddsa-sign", - 8 => "eddsa-verify", - 9 => "ed25519-erase", - 10 => "ecdsa-p256", - 11 => "session-abort", - _ => "unknown", - } - } - // CROSS-CRATE COUPLING: the persist status-word bit layout below MUST match // the encoding produced on the secure side (crates/secure/src/se_persist.rs). // The two crates do not share a type, so it is duplicated by hand and the two @@ -430,32 +381,6 @@ mod firmware } } - /// Logs the decoded crypto + attestation outcome over RTT. - #[cfg(feature = "se-session")] - fn report_crypto(scr: u32) - { - if scr & SCR_ERR != 0 - { - // The low byte is the SeError code, or a RESERVED code: 0xF1 - // EdDSA verify reject, 0xF2 random sanity, 0xF3 ECDSA shape, 0xF4 - // pubkey length. - defmt::error! - ( - "SE crypto FAILED at step {=str}, error code {=u8:#04x}", - crypto_step((scr >> 8) & 0xFF), - scr as u8 - ); - } - else if scr & SCR_OK != 0 - { - defmt::info!("SE crypto + attestation OK, marker {=u8:#04x}", scr as u8); - } - else - { - defmt::warn!("SE crypto word unrecognized {=u32:#010x}", scr); - } - } - /// Logs the decoded persistent-state outcome over RTT. #[cfg(feature = "se-session")] fn report_persist(spr: u32) @@ -593,20 +518,8 @@ mod firmware report_session(ses); - // Crypto + attestation bring-up, run AFTER the session ping. It - // verifies the chain to the pinned root, opens a session on the - // verified STPUB, and runs the TRNG / Ed25519 / P-256 sequence. - // - // SAFETY: the crypto veneer is a CMSE secure-gateway entry taking no - // argument and returning a scalar. Calling it crosses into the secure - // world through the SG veneer. No pointer or caller memory is shared, - // so there is nothing to validate on either side. - let scr = unsafe { patinakey_nsc_se_crypto() }; - - report_crypto(scr); - - // Persistent-but-reversible state bring-up, run after the crypto - // veneer. It opens a session and exercises the monotonic counters, + // Persistent-but-reversible state bring-up, run after the session + // ping. It opens a session and exercises the monotonic counters, // MAC-and-Destroy, and ECC_Key_Store, all reversible (no OTP, config, // or pairing write). // diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index 7e13ba4..8f374f5 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -10,6 +10,7 @@ publish = false description = "STM32U545 MCU platform foundation: TrustZone (SAU/GTZC) runtime partition bring-up." [dependencies] +mcu-layout = { path = "../mcu-layout" } # This crate must touch raw MMIO (the volatile register reads/writes that program SAU/GTZC). # Every unsafe block must carry a `// SAFETY:` justification. diff --git a/crates/platform/src/map.rs b/crates/platform/src/map.rs index f4af24c..f5db8a4 100644 --- a/crates/platform/src/map.rs +++ b/crates/platform/src/map.rs @@ -7,6 +7,9 @@ //! Manual (SAU region encoding), and the board pin map (SE SPI1 on PA4-7 + PB1, //! USB on PA11/PA12, TSC on PB4/PB6). +use mcu_layout::NSC_VENEER_BASE; +use mcu_layout::NSC_VENEER_LIMIT; + use crate::error::PartitionError; use crate::regs::SAU_ALIGN_MASK; use crate::regs::SAU_RLAR_ENABLE; @@ -59,18 +62,13 @@ pub(crate) const MPCBB2_CFGLOCK_MASK: u32 = (1 << 4) - 1; pub(crate) const MPCBB4_CFGLOCK_MASK: u32 = 1; // =========================================================================== -// Flash and address-space regions. RM0456 memory map. The NSC veneer window is -// the top 8 KB of secure Bank 1, where the toolchain places `.gnu.sgstubs`. +// Flash and address-space regions. RM0456 memory map. +// +// The NSC veneer window (`NSC_VENEER_BASE` / `NSC_VENEER_LIMIT`, imported from +// mcu-layout above) is the top 512 bytes of the secure app band, where the +// toolchain places `.gnu.sgstubs`. // =========================================================================== -/// NSC veneer window base: page 19 of the secure app band (.gnu.sgstubs lands -/// here). Layout L1 moves the veneer from the top of the bank to page 19, the -/// top page of the secure image sub-band. RM0456 sec 7.5.8 identical-per-bank -/// layout. Matches crates/secure/memory.x + build.rs `--section-start`. -pub(crate) const NSC_VENEER_BASE: u32 = 0x0C02_6000; -/// NSC veneer window inclusive limit (8 KB, page 19). -pub(crate) const NSC_VENEER_LIMIT: u32 = 0x0C02_7FFF; - /// Non-secure flash alias base: the whole non-secure flash alias, not just the /// high bank. /// @@ -482,14 +480,22 @@ mod tests assert_eq!(MPU_SRAM_LIMIT, 0x2001_FFFF); // Layout L1: the code region is pages 2-19 of the active bank, excluding // the metadata band (pages 0-1) so a metadata WRITE never lands in the RX - // region. It includes the NSC veneer window (page 19). + // region. It includes the NSC veneer window at its top. assert_eq!(MPU_CODE_BASE, 0x0C00_4000); assert_eq!(MPU_CODE_LIMIT, 0x0C02_7FFF); let veneer_in_code = NSC_VENEER_BASE >= MPU_CODE_BASE && NSC_VENEER_LIMIT <= MPU_CODE_LIMIT; assert!(veneer_in_code, "NSC veneer must lie inside the code region"); - assert_eq!(NSC_VENEER_BASE, 0x0C02_6000); - assert_eq!(NSC_VENEER_LIMIT, 0x0C02_7FFF); + // DRIFT GUARD. + assert_eq! + ( + NSC_VENEER_LIMIT, MPU_CODE_LIMIT, + "the NSC veneer window must end at the top of the secure code region" + ); + // SAU granule: RBAR fixes a base's low 5 bits to zero, RLAR reads a + // limit's low 5 bits as one. + assert_eq!(NSC_VENEER_BASE & SAU_ALIGN_MASK, 0); + assert_eq!(NSC_VENEER_LIMIT & SAU_ALIGN_MASK, SAU_ALIGN_MASK); // The swap-derived metadata region is pages 0-1 (16 KB) of physical Bank // 1, at the low alias when SWAP_BANK is clear and the high alias when set. assert_eq!(MPU_META_LOW_BASE, 0x0C00_0000); diff --git a/crates/secure/Cargo.toml b/crates/secure/Cargo.toml index 5bc827a..3d20013 100644 --- a/crates/secure/Cargo.toml +++ b/crates/secure/Cargo.toml @@ -22,13 +22,13 @@ normal = ["platform"] # the whole path on. default = [] se-fw-update = [] -# se-session: adds the L3 secure-channel bring-up path (secure body plus its NSC -# veneer). Reads STPUB, opens a Noise KK1 session against slot 0, runs one L3 -# Ping with an echo compare, then aborts. -# Also adds a crypto + attestation bring-up path (se_crypto.rs): it turns on the -# driver attestation feature (chain verify to the pinned root) and links -# ed25519-dalek to verify an SE-produced EdDSA signature on-host. -se-session = ["tropic01-driver/attestation", "dep:ed25519-dalek"] +# se-session: adds the L3 secure-channel bring-up paths (secure bodies plus their +# NSC veneers). Reads STPUB, opens a Noise KK1 session against slot 0, runs one L3 +# Ping with an echo compare, then aborts. It also adds the persistent-state and +# read-only sweep paths. +# ed25519-dalek backs the known-answer test in se_persist.rs, which proves the +# chip's ECC_Key_Store plus EdDSA_Sign path against a standard RFC 8032 verifier. +se-session = ["dep:ed25519-dalek"] [dependencies] platform = { path = "../platform" } @@ -37,6 +37,7 @@ platform = { path = "../platform" } # the de-facto, mature, mainstream build-script C compiler driver for Rust. [build-dependencies] cc = "1.2" +mcu-layout = { path = "../mcu-layout" } # Embedded-only deps. On the host the bin # is an empty stub (see src/main.rs), so these compile only for the target. diff --git a/crates/secure/build.rs b/crates/secure/build.rs index c9d470c..f3b4686 100644 --- a/crates/secure/build.rs +++ b/crates/secure/build.rs @@ -2,9 +2,10 @@ //! //! For the embedded target only this build script does three things: //! 1. emits `memory.x` (the secure FLASH / RAM layout) and `sgstubs.x` (roots -//! the NSC entry symbols so --gc-sections keeps their veneers) onto the -//! linker search path so cortex-m-rt's `link.x` composes with them, and -//! pins `.gnu.sgstubs` to the NSC address with a `--section-start`. +//! the NSC entry symbols so --gc-sections keeps their veneers, and asserts +//! the veneers stay inside the NSC window) onto the linker search path so +//! cortex-m-rt's `link.x` composes with them, and pins `.gnu.sgstubs` to +//! the NSC address with a `--section-start`. //! 2. compiles the C `-mcmse` NSC veneer shim (`csrc/secure_nsc.c`) with clang //! into the crate's object set. //! 3. drives rust-lld to emit the CMSE import library (the SG-veneer import @@ -30,20 +31,19 @@ use std::env; use std::error::Error; +use std::fmt::Write as _; use std::fs; use std::path::Path; use std::path::PathBuf; +use mcu_layout::NSC_VENEER_BASE; +use mcu_layout::NSC_VENEER_LEN; +use mcu_layout::NSC_VENEER_LIMIT; + /// The bare-metal triple the secure/non-secure images are built for. const TARGET_TRIPLE: &str = "thumbv8m.main-none-eabihf"; /// The stable file name of the CMSE import object under the target triple dir. const IMPLIB_FILE: &str = "patinakey_nsc_implib.o"; -/// The pinned NSC veneer window base: page 19, the top page of the secure app -/// band (pages 10-19). The CMSE secure-gateway veneers (.gnu.sgstubs) are forced -/// here so the SAU-marked NSC address is stable across builds. It sits inside -/// the secure FLASH region [0x0C014000, 0x0C028000). RM0456 memory map matches -/// platform map.rs. -const NSC_VENEER_BASE: &str = "0x0C026000"; /// Derives the cargo target-root directory from `OUT_DIR`. /// @@ -61,6 +61,42 @@ fn target_root_from_out_dir(out_dir: &Path) -> Option .map(Path::to_path_buf) } +/// Builds the linker assertions that bind the emitted veneers to the NSC window. +/// +/// Returns a linker-script fragment appended to `sgstubs.x`. It states three +/// bounds the linker checks on every build, so a violation is a build error +/// instead of a silicon fault: +/// 1. `.gnu.sgstubs` landed at the base the SAU marks Non-Secure-Callable, so +/// a toolchain that stopped honouring the `--section-start` is caught, +/// 2. the veneers fit inside the window, so adding NSC entries past its +/// capacity fails the link instead of spilling into ordinary code, +/// 3. memory.x ends the secure FLASH band exactly at the window top. +/// +/// # Errors +/// +/// `std::fmt::Error` if writing into the returned buffer fails. +fn nsc_window_assertions() -> Result +{ + let mut out = String::new(); + writeln!( + out, + "ASSERT(ADDR(.gnu.sgstubs) == {NSC_VENEER_BASE:#010X}, \ + \"CMSE veneers must land at the SAU Non-Secure-Callable window base\");" + )?; + writeln!( + out, + "ASSERT(SIZEOF(.gnu.sgstubs) <= {NSC_VENEER_LEN}, \ + \"CMSE veneers overflow the SAU Non-Secure-Callable window\");" + )?; + writeln!( + out, + "ASSERT(ORIGIN(FLASH) + LENGTH(FLASH) == {:#010X}, \ + \"secure FLASH band must end at the top of the NSC window\");", + NSC_VENEER_LIMIT + 1 + )?; + Ok(out) +} + fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=memory.x"); @@ -100,10 +136,10 @@ fn main() -> Result<(), Box> if se_session { sgstubs.extend_from_slice(b"EXTERN(patinakey_nsc_se_session_ping);\n"); - sgstubs.extend_from_slice(b"EXTERN(patinakey_nsc_se_crypto);\n"); sgstubs.extend_from_slice(b"EXTERN(patinakey_nsc_se_persist);\n"); sgstubs.extend_from_slice(b"EXTERN(patinakey_nsc_se_readonly);\n"); } + sgstubs.extend_from_slice(nsc_window_assertions()?.as_bytes()); fs::write(out_dir.join("sgstubs.x"), sgstubs)?; println!("cargo:rustc-link-search={}", out_dir.display()); // Append the sgstubs fragment after link.x so its EXTERN roots the veneers. @@ -112,8 +148,7 @@ fn main() -> Result<(), Box> // emits .gnu.sgstubs into FLASH without a fixed address. This forces it to // the SAU-marked NSC base so the address is stable and the NS world can call it. println!( - "cargo:rustc-link-arg=--section-start=.gnu.sgstubs={}", - NSC_VENEER_BASE + "cargo:rustc-link-arg=--section-start=.gnu.sgstubs={NSC_VENEER_BASE:#010X}" ); // 2. Compile the C -mcmse NSC veneer shim with clang. The cc crate is forced diff --git a/crates/secure/csrc/secure_nsc.c b/crates/secure/csrc/secure_nsc.c index ca172ac..04e26e1 100644 --- a/crates/secure/csrc/secure_nsc.c +++ b/crates/secure/csrc/secure_nsc.c @@ -86,19 +86,6 @@ __attribute__((cmse_nonsecure_entry)) uint32_t patinakey_nsc_se_session_ping(voi return patinakey_se_session_ping(); } -/* Crypto + attestation bring-up veneer. - * The Rust secure body (src/se_crypto.rs) verifies the chip cert chain - * to the pinned production root, opens a session on the verified STPUB, runs the - * TRNG / Ed25519 / P-256 sequence, aborts, and packs the outcome (which step, - * success or failure) into a uint32_t. -*/ -extern uint32_t patinakey_se_crypto(void); - -__attribute__((cmse_nonsecure_entry)) uint32_t patinakey_nsc_se_crypto(void) -{ - return patinakey_se_crypto(); -} - /* Persistent-but-reversible state bring-up veneer. * The Rust secure body (src/se_persist.rs) opens a session, exercises the * monotonic counters, MAC-and-Destroy, and ECC_Key_Store, aborts, diff --git a/crates/secure/memory.x b/crates/secure/memory.x index d35e32d..987b7bc 100644 --- a/crates/secure/memory.x +++ b/crates/secure/memory.x @@ -11,15 +11,22 @@ * inactive bank presents it at the high alias 0x0C05_4000, so SAU, SECWM and the * MPU never change on a bank swap. * - * The Non-Secure-Callable veneer window is page 19 at 0x0C02_6000 (the top page - * of this band). The build pins the CMSE secure-gateway veneers (.gnu.sgstubs) - * there with a linker --section-start (see build.rs), so they land at the fixed - * address the SAU marks Non-Secure-Callable. Ordinary secure code/data uses - * pages 10-18 (72 KB) below the veneer. The single FLASH region (not a separate - * carve-out) is deliberate: cortex-m-rt's link.x assigns .gnu.sgstubs to FLASH, - * so a second region would leave that section's region unbound. The - * --section-start instead pins the address inside the one FLASH region. RM0456 - * memory map (Bank secure alias, sec 7.5.8 identical-per-bank layout). + * The Non-Secure-Callable veneer window sits at the top of this band. The build + * pins the CMSE secure-gateway veneers (.gnu.sgstubs) there with a linker + * --section-start (see build.rs), so they land at the fixed address the SAU marks + * Non-Secure-Callable. Ordinary secure code and data get the rest of the band, + * from the FLASH ORIGIN below up to the window base. The window is 32-byte + * aligned, the SAU granule, and is deliberately NOT page aligned: a page-aligned + * window spent 8 KB of code space and handed the non-secure world an 8 KB + * callable surface for a few dozen bytes of gateways. + * + * The single FLASH region (not a separate carve-out) is deliberate: cortex-m-rt's + * link.x assigns .gnu.sgstubs to FLASH, so a second region would leave that + * section's region unbound. The --section-start instead pins the address inside + * the one FLASH region, and build.rs emits linker ASSERTs that the veneers land + * at the window base, fit inside it, and that this region ends exactly at the + * window top. RM0456 memory map (Bank secure alias, sec 7.5.8 + * identical-per-bank layout). * * RAM is the lower 128 KB of SRAM1 at 0x2000_0000 (the secure RAM half), * matching the SAU region 2 / MPCBB1 split declared in platform's map.rs. diff --git a/crates/secure/sgstubs.x b/crates/secure/sgstubs.x index c12346a..9a0bc80 100644 --- a/crates/secure/sgstubs.x +++ b/crates/secure/sgstubs.x @@ -10,7 +10,9 @@ * .gnu.sgstubs output section. A competing section definition would leave lld's * synthesized veneers without an assigned address). * - * Add one EXTERN line per NSC entry exported by csrc/secure_nsc.c. + * Add one EXTERN line per NSC entry exported by csrc/secure_nsc.c. build.rs + * appends the feature-gated EXTERNs, then the ASSERTs that bound .gnu.sgstubs to + * the NSC window (base, size, and the secure FLASH band's end). * Armv8-M secure gateway / .gnu.sgstubs convention. cortex-m-rt link.x. */ EXTERN(patinakey_nsc_version); diff --git a/crates/secure/src/main.rs b/crates/secure/src/main.rs index a181b6f..8d72196 100644 --- a/crates/secure/src/main.rs +++ b/crates/secure/src/main.rs @@ -40,12 +40,6 @@ mod se_fw_update; #[cfg(all(target_os = "none", feature = "se-session"))] mod se_session; -// The crypto + attestation bring-up path (secure side of the crypto veneer). -// Feature-gated under the se-session feature: OFF by default, so the -// product firmware is byte-unchanged and never references it. -#[cfg(all(target_os = "none", feature = "se-session"))] -mod se_crypto; - // The persistent-but-reversible state bring-up path. // Feature-gated under the se-session feature: OFF by default, so the // product firmware is byte-unchanged and never references it. diff --git a/crates/secure/src/se_crypto.rs b/crates/secure/src/se_crypto.rs deleted file mode 100644 index ed50b60..0000000 --- a/crates/secure/src/se_crypto.rs +++ /dev/null @@ -1,444 +0,0 @@ -//! Secure-world TROPIC01 crypto + attestation bring-up, exported to the NSC veneer. -//! -//! Proves real ECC crypto UNDER an L3 session plus the full X.509 attestation -//! chain on silicon: verify the chip certificate chain up to the PINNED -//! production Tropic Square root, open a Noise KK1 session on the VERIFIED -//! STPUB, then run a sequence of session-encrypted commands (TRNG draw, Ed25519 -//! generate/sign/verify, P-256 generate/sign/shape-check) and tear down with the -//! chip-notifying abort. It is the secure side of the `patinakey_nsc_se_crypto` -//! non-secure-callable veneer: the non-secure world calls the veneer, the veneer -//! forwards here, this code drives the flow, packs the outcome into a `u32`, and -//! returns. -//! -//! FEATURE-GATED: the whole module compiles ONLY under the `se-session` cargo -//! feature. With the feature off the product firmware is byte-unchanged and never -//! references this path. -//! -//! BRING-UP ONLY: this path uses a FIXED ephemeral key and the PUBLIC factory -//! slot-0 pairing key (both re-used from se_session.rs). It is a silicon test, -//! never a product build. -//! -//! QUARANTINE: the `extern "C"` entry needs `#[unsafe(no_mangle)]` so the C -//! veneer in csrc/secure_nsc.c can resolve it by its C ABI name. - -use tropic01_driver::EccCurve; -use tropic01_driver::EccSlot; -use tropic01_driver::RootAnchor; -use tropic01_driver::SeCommands; -use tropic01_driver::SeError; - -use crate::se_session::open_bringup_session; -use crate::se_session::BringupSession; -use crate::se_session::CERT_SCRATCH_LEN; -use crate::se_smoke::build_device; -use crate::se_smoke::se_error_code; - -/// Pinned Tropic Square Root CA v1 public key, serial 301, P-521, SEC1 -/// uncompressed (133 bytes). -/// -/// This is a PUBLIC vendor certificate, published with the Tropic Square SDK and -/// at pki.tropicsquare.com. It is the out-of-band trust anchor the chip -/// certificate chain is verified against. A corrupted constant is rejected by -/// [`RootAnchor::from_sec1_p521`] (off-curve or bad prefix), so the attestation -/// step fails loudly rather than trusting a wrong root. -const TROPIC_ROOT_CA_V1_SEC1: [u8; 133] = -[ - 0x04, 0x01, 0x87, 0xcc, 0xea, 0x62, 0x83, 0x7e, - 0x23, 0x09, 0x2d, 0x8a, 0x71, 0x35, 0x78, 0x9f, - 0xcc, 0x6f, 0xbc, 0x3d, 0x35, 0xe7, 0x9f, 0xc0, - 0x1f, 0x4f, 0x49, 0x8f, 0xc5, 0xc2, 0xc4, 0x09, - 0xce, 0x77, 0x2f, 0x90, 0x13, 0x40, 0x09, 0x04, - 0x03, 0xe8, 0xba, 0x4d, 0x97, 0xe1, 0x3f, 0x1e, - 0x75, 0x94, 0xac, 0x6d, 0x2f, 0x51, 0xfd, 0x22, - 0x39, 0xf8, 0xd4, 0x57, 0x76, 0x9f, 0x37, 0x84, - 0x40, 0xa1, 0x80, 0x00, 0x71, 0x2b, 0xf1, 0x6a, - 0x48, 0xea, 0x20, 0x25, 0x83, 0x7b, 0xef, 0xd0, - 0x50, 0x2a, 0x56, 0x2f, 0xd9, 0x39, 0x41, 0xd5, - 0x2c, 0xc4, 0x0e, 0xd9, 0x55, 0x3c, 0xa7, 0x9b, - 0x14, 0x5b, 0xa5, 0x85, 0xf3, 0x24, 0x92, 0xbf, - 0xd7, 0x92, 0xeb, 0x96, 0xd9, 0x49, 0xd3, 0x16, - 0x76, 0xcd, 0x09, 0x9f, 0x19, 0xce, 0x88, 0x48, - 0x69, 0x7b, 0x8c, 0x34, 0x30, 0xaf, 0x01, 0x6f, - 0xed, 0x98, 0x5e, 0x1e, 0xb4, -]; - -/// Fixed ASCII message signed by the Ed25519 path and verified on-host. -/// -/// The chip hashes it internally (RFC 8032). The host verifier re-checks the -/// signature over these exact bytes, so an SE-produced EdDSA signature is proven -/// to verify under a standard verifier. -const MESSAGE: &[u8] = b"patinakey EdDSA attest"; - -/// Fixed 32-byte digest signed by the P-256 (ECDSA) path. -/// -/// An arbitrary documented byte pattern. The chip signs a caller-supplied digest -/// (the host pre-hashes with SHA-256 in production), so any fixed 32 bytes prove -/// the command round trip. No P-256 verifier is linked, so only the signature -/// shape is checked. -const DIGEST: [u8; 32] = -[ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, - 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, -]; - -/// ECC slot the Ed25519 test key lives in (bring-up scratch slot). -const ED25519_SLOT: u8 = 31; -/// ECC slot the P-256 test key lives in (bring-up scratch slot). -const P256_SLOT: u8 = 30; - -// Status-word encoding. -// -// CROSS-CRATE COUPLING: SCR_OK / SCR_ERR / the step codes / SCR_OK_MARKER MUST -// match the encoding decoded on the non-secure side -// (crates/nonsecure/src/main.rs). The two crates do not share a type, so the bit -// layout is duplicated by hand and the two copies must stay in sync. -// -// Layout: -// bit 31 SCR_ERR : the crypto bring-up failed. -// bit 8 SCR_OK : the crypto bring-up succeeded. -// bits 15..8 (on ERR) step code: which step failed (1..=11, 0x01..=0x0B). -// bits 7..0 (on ERR) the SeError code (se_error_code, shared with se_smoke), -// or a RESERVED non-SeError code (0xF1..0xF4, see below). -// bits 7..0 (on OK) SCR_OK_MARKER: a fixed pattern the NS logs as "crypto + -// attestation OK". -// An error word can also set bit 8 incidentally (an odd step shifts a 1 into bit -// 8 via step << 8). SCR_ERR (bit 31) is the discriminator: the NS tests SCR_ERR -// FIRST, so an error word with bit 8 set is read as an error. -// -// RESERVED non-SeError low-byte codes (secure-side, ONE place). These are NOT -// se_error_code values, so they never collide with a real SeError code: -// 0xF0 echo mismatch (used by se_session.rs, not here). -// 0xF1 EdDSA signature verify reject (host verifier rejected the signature). -// 0xF2 random sanity failure (all 32 TRNG bytes identical). -// 0xF3 ECDSA signature shape failure (R half or S half all zero). -// 0xF4 Ed25519 public-key length mismatch (not the expected 32 bytes). - -/// Status bit: the crypto bring-up succeeded. -const SCR_OK: u32 = 1 << 8; -/// Status bit: the crypto bring-up failed. Bits 15..8 then carry the step, bits -/// 7..0 the [`SeError`] code or a RESERVED code. -const SCR_ERR: u32 = 1 << 31; - -/// Low-byte marker returned on success. The non-secure side logs it as "crypto + -/// attestation OK". It appears only with bit 31 clear, error codes only with bit -/// 31 set, so there is no ambiguity. -const SCR_OK_MARKER: u32 = 0x52; - -/// RESERVED code: the host EdDSA verifier rejected the SE-produced signature (or -/// the pubkey did not parse). Not an [`SeError`]. -const SCR_EDDSA_REJECT: u32 = 0xF1; -/// RESERVED code: the TRNG draw returned 32 identical bytes (a dead bus or stuck -/// RNG). Not an [`SeError`]. -const SCR_RANDOM_SANITY: u32 = 0xF2; -/// RESERVED code: the ECDSA signature had an all-zero R or S half. Not an -/// [`SeError`]. -const SCR_ECDSA_SHAPE: u32 = 0xF3; -/// RESERVED code: the Ed25519 public key was not the expected 32 bytes. Not an -/// [`SeError`]. Only the Ed25519 read checks length, the P-256 path reads no -/// public key. -const SCR_PUBKEY_LEN: u32 = 0xF4; - -/// Step code: verify the chain and read the VERIFIED STPUB (attestation). -const STEP_ATTEST: u32 = 0x01; -/// Step code: open the Noise KK1 session on the verified STPUB. -const STEP_OPEN_SESSION: u32 = 0x02; -/// Step code: draw 32 TRNG bytes and sanity-check them. -const STEP_RANDOM: u32 = 0x03; -// Step 4 (pre-clean, 0x04) has no secure-side error word: its erase result is -// deliberately ignored (see the pre-clean comment in the flow), so no -// STEP_PRE_CLEAN const exists. The non-secure decoder still labels step 4 for -// completeness. -/// Step code: generate the Ed25519 key. -const STEP_ED_GENERATE: u32 = 0x05; -/// Step code: read the Ed25519 public key. -const STEP_ED_PUBKEY: u32 = 0x06; -/// Step code: sign MESSAGE with the Ed25519 key. -const STEP_ED_SIGN: u32 = 0x07; -/// Step code: verify the EdDSA signature on-host. -const STEP_ED_VERIFY: u32 = 0x08; -/// Step code: erase the Ed25519 key (checked). -const STEP_ED_ERASE: u32 = 0x09; -/// Step code: the P-256 generate/sign/shape/erase round trip. -const STEP_ECDSA: u32 = 0x0A; -/// Step code: the chip-notifying session abort. -const STEP_SESSION_ABORT: u32 = 0x0B; - -/// Packs a failing step and an [`SeError`] into the error status word. -/// -/// `SCR_ERR | (step << 8) | error_code`. The step lives in bits 15..8, the error -/// code in the low byte, so the non-secure log names both the failing step and -/// the fault. -fn err_word(step: u32, err: SeError) -> u32 -{ - SCR_ERR | (step << 8) | se_error_code(err) -} - -/// Packs a failing step and a RESERVED low-byte code into the error status word. -/// -/// Used for the non-[`SeError`] sanity codes (0xF1..0xF4). Same layout as -/// [`err_word`] but with a caller-supplied low byte. -fn err_word_code(step: u32, code: u32) -> u32 -{ - SCR_ERR | (step << 8) | (code & 0xFF) -} - -/// Verifies an Ed25519 signature over MESSAGE under a 32-byte public key. -/// -/// Uses the standard RFC 8032 verifier (`ed25519_dalek`). Returns true only when -/// the pubkey parses AND the strict verification passes. A parse or verify -/// failure returns false, which the caller maps to [`SCR_EDDSA_REJECT`]. -fn eddsa_verify_host(pubkey: &[u8], signature: &[u8; 64]) -> bool -{ - let key_bytes: [u8; 32] = match pubkey.try_into() - { - Ok(bytes) => bytes, - Err(_) => return false, - }; - let verifying_key = match ed25519_dalek::VerifyingKey::from_bytes(&key_bytes) - { - Ok(key) => key, - Err(_) => return false, - }; - let sig = ed25519_dalek::Signature::from_bytes(signature); - verifying_key.verify_strict(MESSAGE, &sig).is_ok() -} - -/// Step 3: draw 32 TRNG bytes and sanity-check them. -/// -/// A short read or an all-identical draw is a dead bus or stuck RNG. Returns the -/// packed error word on any fault and never tears the session down, the caller -/// owns teardown. -fn run_random_check(session: &mut BringupSession) -> Result<(), u32> -{ - let mut rnd = [0u8; 32]; - match session.random_into(&mut rnd) - { - Ok(n) if n == rnd.len() => {} - Ok(_) => return Err(err_word_code(STEP_RANDOM, SCR_RANDOM_SANITY)), - Err(e) => return Err(err_word(STEP_RANDOM, e)), - } - if rnd.iter().all(|&b| b == rnd[0]) - { - return Err(err_word_code(STEP_RANDOM, SCR_RANDOM_SANITY)); - } - Ok(()) -} - -/// Steps 4..9: the Ed25519 generate / read / sign / host-verify / erase round -/// trip. -/// -/// Pre-clean, generate, read the 32-byte pubkey, sign MESSAGE, host-verify, then -/// checked erase. On a fault after generate the slot is erased best-effort before -/// returning, so the erase-before-teardown order holds once the caller tears -/// down. Never tears the session down itself. -fn run_ed25519_kat -( - session: &mut BringupSession, - ed_slot: EccSlot, -) - -> Result<(), u32> -{ - // Step 4: best-effort pre-clean erase, result IGNORED. - let _ = session.ecc_key_erase(ed_slot); - - // Step 5: generate the Ed25519 key. On error erase best-effort then return. - if let Err(e) = session.ecc_key_generate(ed_slot, EccCurve::Ed25519) - { - let _ = session.ecc_key_erase(ed_slot); - return Err(err_word(STEP_ED_GENERATE, e)); - } - - // Step 6: read the Ed25519 public key. Expect exactly 32 bytes. - let ed_pubkey = match session.ecc_public_key(ed_slot) - { - Ok(key) => key, - Err(e) => - { - let _ = session.ecc_key_erase(ed_slot); - return Err(err_word(STEP_ED_PUBKEY, e)); - } - }; - if ed_pubkey.bytes().len() != 32 - { - let _ = session.ecc_key_erase(ed_slot); - return Err(err_word_code(STEP_ED_PUBKEY, SCR_PUBKEY_LEN)); - } - let mut ed_pub_bytes = [0u8; 32]; - ed_pub_bytes.copy_from_slice(ed_pubkey.bytes()); - - // Step 7: sign MESSAGE with the Ed25519 key (EdDSA). - let ed_sig = match session.eddsa_sign(ed_slot, MESSAGE) - { - Ok(sig) => sig, - Err(e) => - { - let _ = session.ecc_key_erase(ed_slot); - return Err(err_word(STEP_ED_SIGN, e)); - } - }; - - // Step 8: verify the 64-byte signature on-host under the 32-byte pubkey. - if !eddsa_verify_host(&ed_pub_bytes, &ed_sig.0) - { - let _ = session.ecc_key_erase(ed_slot); - return Err(err_word_code(STEP_ED_VERIFY, SCR_EDDSA_REJECT)); - } - - // Step 9: checked erase of the Ed25519 key. - if let Err(e) = session.ecc_key_erase(ed_slot) - { - return Err(err_word(STEP_ED_ERASE, e)); - } - Ok(()) -} - -/// Step 10: the P-256 (ECDSA) generate / sign / shape-check / erase round trip. -/// -/// No P-256 verifier is linked, so this proves the command round trip and the -/// signature shape, NOT cryptographic verification. On a fault the slot is erased -/// best-effort before returning, so the erase-before-teardown order holds once -/// the caller tears down. Never tears the session down itself. -fn run_ecdsa_roundtrip -( - session: &mut BringupSession, - p256_slot: EccSlot, -) - -> Result<(), u32> -{ - let _ = session.ecc_key_erase(p256_slot); - if let Err(e) = session.ecc_key_generate(p256_slot, EccCurve::P256) - { - let _ = session.ecc_key_erase(p256_slot); - return Err(err_word(STEP_ECDSA, e)); - } - let p256_sig = match session.ecdsa_sign(p256_slot, &DIGEST) - { - Ok(sig) => sig, - Err(e) => - { - let _ = session.ecc_key_erase(p256_slot); - return Err(err_word(STEP_ECDSA, e)); - } - }; - // SHAPE check: 64 bytes with neither the R half (first 32) nor the S half - // (last 32) all zero. An all-zero half is a malformed signature. - let r_zero = p256_sig.0[..32].iter().all(|&b| b == 0); - let s_zero = p256_sig.0[32..].iter().all(|&b| b == 0); - if r_zero || s_zero - { - let _ = session.ecc_key_erase(p256_slot); - return Err(err_word_code(STEP_ECDSA, SCR_ECDSA_SHAPE)); - } - if let Err(e) = session.ecc_key_erase(p256_slot) - { - return Err(err_word(STEP_ECDSA, e)); - } - Ok(()) -} - -/// Runs steps 3..10 under the open session, in order. -/// -/// Returns the packed error word at the first failing step and never tears the -/// session down. The caller owns the single teardown, so a helper that erased a -/// slot before returning keeps the erase-before-teardown order. -fn run_crypto_under_session -( - session: &mut BringupSession, - ed_slot: EccSlot, - p256_slot: EccSlot, -) - -> Result<(), u32> -{ - run_random_check(session)?; - run_ed25519_kat(session, ed_slot)?; - run_ecdsa_roundtrip(session, p256_slot)?; - Ok(()) -} - -/// Runs the crypto + attestation bring-up and returns a packed status word. -/// -/// Drives the flow step by step so the returned word names WHICH step failed: -/// 1. verify the chain to the pinned root and read the VERIFIED STPUB, -/// 2. open the Noise KK1 session on that STPUB (shared with se_session.rs), -/// 3. draw 32 TRNG bytes and sanity-check them, -/// 4. best-effort pre-clean erase of the Ed25519 slot (result ignored), -/// 5. generate an Ed25519 key, -/// 6. read its public key (expect 32 bytes), -/// 7. sign MESSAGE (EdDSA), -/// 8. verify the signature on-host under the public key, -/// 9. erase the Ed25519 key (checked), -/// 10. generate a P-256 key, sign DIGEST (ECDSA), shape-check, erase (checked), -/// 11. chip-notifying teardown. -/// -/// The chip must already be in Application FW mode (the L3 channel lives there). -/// -/// On any [`SeError`] returns [`err_word`]. On a sanity or verify failure returns -/// [`err_word_code`] with the matching RESERVED code. On success returns `SCR_OK -/// | SCR_OK_MARKER`. If a session is open when a step fails, it is torn down -/// before returning. -/// -/// This is the non-secure-callable entry the crypto veneer forwards to. -// QUARANTINE: the stable C export name needs `#[unsafe(no_mangle)]`. -#[allow(unsafe_code)] -#[unsafe(no_mangle)] -pub extern "C" fn patinakey_se_crypto() -> u32 -{ - let ed_slot = match EccSlot::new(ED25519_SLOT) - { - Ok(slot) => slot, - Err(e) => return err_word(STEP_ATTEST, e), - }; - let p256_slot = match EccSlot::new(P256_SLOT) - { - Ok(slot) => slot, - Err(e) => return err_word(STEP_ATTEST, e), - }; - - let mut dev = build_device(); - - // Step 1: build the pinned anchor and verify the FULL chain, returning the - // VERIFIED STPUB. - let anchor = match RootAnchor::from_sec1_p521(&TROPIC_ROOT_CA_V1_SEC1) - { - Ok(anchor) => anchor, - Err(e) => return err_word(STEP_ATTEST, e), - }; - let mut scratch = [0u8; CERT_SCRATCH_LEN]; - let stpub = match dev.read_verified_chip_stpub(&mut scratch, &anchor) - { - Ok(stpub) => stpub, - Err(e) => return err_word(STEP_ATTEST, e), - }; - - // Step 2: open the Noise KK1 session on the VERIFIED STPUB. The shared helper - // uses the same prod0 SH0 keys and fixed ephemeral as se_session.rs. - let mut session = match open_bringup_session(dev, &stpub) - { - Ok(session) => session, - Err((_dev, e)) => return err_word(STEP_OPEN_SESSION, e), - }; - - // Steps 3..10 run under the open session. On a failure the session is torn - // down and the returned word is surfaced, matching the original per-step - // teardown. - match run_crypto_under_session(&mut session, ed_slot, p256_slot) - { - Err(word) => - { - let (_dev, _ack) = session.abort_session(); - word - } - Ok(()) => - { - // Step 11: chip-notifying teardown. - let (_dev, ack) = session.abort_session(); - match ack - { - Ok(()) => SCR_OK | SCR_OK_MARKER, - Err(e) => err_word(STEP_SESSION_ABORT, e), - } - } - } -} diff --git a/crates/secure/src/se_persist.rs b/crates/secure/src/se_persist.rs index a47df07..967b016 100644 --- a/crates/secure/src/se_persist.rs +++ b/crates/secure/src/se_persist.rs @@ -48,7 +48,8 @@ const MCOUNTER_IDX: u8 = 15; const MAC_DESTROY_SLOT: u8 = 127; /// ECC slot the imported Ed25519 test key lives in (bring-up scratch slot). /// -/// 29 avoids slots 30 and 31, which the crypto bring-up (se_crypto.rs) uses. +/// 29 keeps this key off slot 28 (se_readonly.rs), so the feature-gated bring-up +/// paths never share an ECC slot. const ECC_SLOT: u8 = 29; /// The counter value the first init sets (step 2). @@ -532,8 +533,8 @@ pub extern "C" fn patinakey_se_persist() -> u32 let mut dev = build_device(); // Step 1: read STPUB from the chip certificate, then open the Noise KK1 - // session on slot 0 via the shared helper (identical prod0 SH0 keys and fixed - // ephemeral as the session and crypto paths). On a read error the chip is + // session on slot 0 via the shared helper (the same prod0 SH0 keys and fixed + // ephemeral every bring-up path uses). On a read error the chip is // untouched, so no teardown is owed. On an open error the helper returns the // NoSession handle plus the error, both dropped here. let mut scratch = [0u8; CERT_SCRATCH_LEN]; diff --git a/crates/secure/src/se_readonly.rs b/crates/secure/src/se_readonly.rs index 386eea7..5bf2b59 100644 --- a/crates/secure/src/se_readonly.rs +++ b/crates/secure/src/se_readonly.rs @@ -50,8 +50,8 @@ use crate::se_smoke::se_error_code; /// ECC slot for the P-256 test key. /// -/// 28 keeps this key off slots 29 (se_persist.rs) and 30, 31 (se_crypto.rs), so -/// the feature-gated tests never share an ECC slot. +/// 28 keeps this key off slot 29 (se_persist.rs), so the feature-gated bring-up +/// paths never share an ECC slot. const P256_SLOT: u8 = 28; /// High user R-Memory slot read then erased. @@ -72,7 +72,7 @@ const RMEM_READ_BUF: usize = 512; /// Fixed 32-byte digest signed by the P-256 (ECDSA) path. /// -/// An arbitrary byte pattern, the same one se_crypto.rs uses. The chip +/// An arbitrary documented byte pattern. The chip /// signs a caller-supplied digest (the host pre-hashes with SHA-256 in /// production), so any fixed 32 bytes prove the command round trip. The host /// verifier re-checks the exported signature over exactly these bytes. diff --git a/crates/secure/src/se_session.rs b/crates/secure/src/se_session.rs index 3b04e42..e87c565 100644 --- a/crates/secure/src/se_session.rs +++ b/crates/secure/src/se_session.rs @@ -41,8 +41,8 @@ use crate::se_smoke::se_error_code; /// secrets. They let a bring-up test open a session against a factory-default /// slot 0 before any provisioning writes a real pairing key. /// -/// Shared with the crypto path (se_crypto.rs) through [`open_bringup_session`] -/// so both open the session with identical keys. +/// Shared with the persistent-state and read-only paths through +/// [`open_bringup_session`] so every bring-up opens with identical keys. pub(crate) const SH0_PRIV: [u8; 32] = [ 0x28, 0x3f, 0x5a, 0x0f, 0xfc, 0x41, 0xcf, 0x50, @@ -71,7 +71,8 @@ pub(crate) const SH0_PUB: [u8; 32] = /// PRODUCTION session opening MUST draw the ephemeral from a real TRNG. /// This module is bring-up only and never enters the product build. /// -/// Shared with the crypto path (se_crypto.rs) through [`open_bringup_session`]. +/// Shared with the persistent-state and read-only paths through +/// [`open_bringup_session`]. pub(crate) const EPHEMERAL_PRIV: [u8; 32] = [ 0xa5, 0x5a, 0xa5, 0x5a, 0xa5, 0x5a, 0xa5, 0x5a, @@ -89,8 +90,8 @@ const PING_PAYLOAD: &[u8] = b"patinakey L3 ping"; /// the full store buffer up front. STPUB is returned by value, so the buffer is /// not retained after the read. /// -/// Shared with the crypto path (se_crypto.rs), which reads the VERIFIED STPUB -/// into a scratch of the same size. +/// Shared with the persistent-state and read-only paths, which read STPUB into a +/// scratch of the same size. pub(crate) const CERT_SCRATCH_LEN: usize = 3840; // Status-word encoding. @@ -166,12 +167,11 @@ pub(crate) type BringupSession = /// Opens the Noise KK1 bring-up session against slot 0 on a supplied STPUB. /// -/// Shared by the session and crypto paths so both open with byte-identical -/// parameters: the PUBLIC prod0 SH0 pairing key pair and the fixed bring-up -/// ephemeral. `stpub` is the chip static public key the caller has read (from -/// the plain cert store for the session path, or the VERIFIED chain for the -/// crypto path). The private keys are wrapped in `Zeroizing` as `SessionConfig` -/// requires and dropped when this returns. +/// Shared by every bring-up path so they open with identical parameters: +/// the PUBLIC prod0 SH0 pairing key pair and the fixed bring-up ephemeral. +/// `stpub` is the chip static public key the caller read from the cert store. +/// The private keys are wrapped in `Zeroizing` as `SessionConfig` requires and +/// dropped when this returns. /// /// On success returns the active-session handle. On failure returns the /// `NoSession` handle plus the [`SeError`], both moved back to the caller. @@ -243,9 +243,9 @@ pub extern "C" fn patinakey_se_session_ping() -> u32 }; // Step 2: open the Noise KK1 session against slot 0 via the shared helper - // (identical prod0 SH0 keys and fixed ephemeral as the crypto path). On error - // open_bringup_session returns the NoSession handle plus the error, both - // dropped here. + // (the same prod0 SH0 keys and fixed ephemeral every bring-up path uses). On + // error open_bringup_session returns the NoSession handle plus the error, + // both dropped here. let mut session = match open_bringup_session(dev, &stpub) { Ok(session) => session, diff --git a/crates/secure/src/se_smoke.rs b/crates/secure/src/se_smoke.rs index 7f92622..3bcace6 100644 --- a/crates/secure/src/se_smoke.rs +++ b/crates/secure/src/se_smoke.rs @@ -81,11 +81,6 @@ pub(crate) fn se_error_code(err: SeError) -> u32 SeError::L3(_) => 0x13, SeError::Handshake(_) => 0x20, SeError::Cert(_) => 0x30, - // The chain-verify error variant is compiled into the driver whenever the - // driver attestation feature is on (the crypto path verifies the chain to - // the pinned root). - #[cfg(feature = "se-session")] - SeError::Chain(_) => 0x31, SeError::SessionLost => 0x40, SeError::NonceExhausted => 0x41, SeError::InvalidArgument => 0x50, @@ -94,16 +89,6 @@ pub(crate) fn se_error_code(err: SeError) -> u32 SeError::FwUpdateIncomplete => 0x61, SeError::FwVersionMismatch => 0x62, SeError::RebootUnsuccessful => 0x63, - // Catch-all arm to handle Cargo feature unification. - // If another crate in the workspace enables the driver's attestation feature, - // the `SeError::Chain` variant is globally compiled into the enum. - // Since we cannot `#[cfg]` check other crates' features, this match would - // fail to compile (E0004) if our own `se-session` feature is off. - // - // This wildcard ensures the match remains exhaustive across all workspace builds. - // We use `allow(unreachable_patterns)` to silence the warning when it's not needed. - #[allow(unreachable_patterns)] - _ => 0x7F, }; code as u32 } diff --git a/docs/bench-runner.md b/docs/bench-runner.md index 70098e2..bcb3348 100644 --- a/docs/bench-runner.md +++ b/docs/bench-runner.md @@ -65,14 +65,23 @@ logs the failing step number and an error code instead. | `FEATURES` | What builds | Live RTT markers | |------------|-------------|------------------| | (none) | the product smoke | first-light SE identity: chip mode, RISC-V and SPECT firmware versions. No `0x5x` marker | -| `se-session` | the full SE proof suite | `0x51` L3 session + encrypted Ping, `0x52` crypto + attestation to the pinned root, `0x53` reversible persistent state, `0x54` safe reads + P-256 export. All four in one flash | +| `se-session` | the SE proof suite | `0x51` L3 session + encrypted Ping, `0x53` reversible persistent state (counters, MAC-and-Destroy, imported-Ed25519 known-answer test), `0x54` safe reads + P-256 export. All three in one flash | | `se-fw-update` | the SE firmware-update path | `0x20` SE firmware update 1.0.0 to 2.0.0 | Notes: -- `se-session` pulls in the attestation feature and the Ed25519 verifier, so the - secure image is larger. The four proofs share one session helper and run back to - back on a single flash. +- `se-session` adds the three secure-side proof bodies, their NSC veneers, and the + host-side Ed25519 verifier (`ed25519-dalek`) that checks the signature the chip + produces from the imported RFC 8032 seed. The secure image is larger as a + result. The three proofs share one session helper and run back to back on a + single flash. +- There is no on-MCU attestation proof. Verifying the + TROPIC01 X.509 chain up to the Tropic Square root is a PROVISIONING-time host + operation, run once on the assembly line to prove the chip genuine. The shipped + firmware pins no Tropic root and verifies no chain: it delegates trust to the + pairing key written into a chip slot at provisioning. The driver keeps the + `attestation` feature for that host tool, and the firmware takes the driver with + `default-features = false`, so none of it links into either image. - `se-fw-update` needs the two vendor firmware blobs present at `crates/secure/fw_blobs/`. They are gitignored (Tropic Square signed artifacts from the libtropic SDK), so an empty checkout cannot build this feature until diff --git a/scripts/ab-bench.sh b/scripts/ab-bench.sh index 0fdd47e..7bc0ca0 100755 --- a/scripts/ab-bench.sh +++ b/scripts/ab-bench.sh @@ -30,6 +30,10 @@ # The YubiKey signing step needs a physical touch + PIN. The private key never # leaves the card. If you prefer to sign by hand, pass SIG=/path/to/sig.raw to # skip the pkcs11-tool call. +# +# FEATURES selects which SE bring-up proof the image runs, for example +# `FEATURES=se-session ab-bench.sh all`. It applies to the secure and non-secure +# images together. set -euo pipefail @@ -127,11 +131,20 @@ cmd_preflight() cmd_build() { require_tool cargo + local feat=() + if [ -n "${FEATURES:-}" ] + then + local names=(${FEATURES}) + feat=(--features "$(IFS=,; echo "${names[*]}")") + log "features: ${names[*]}" + fi + touch "${REPO_ROOT}/crates/secure/csrc/secure_nsc.c" # Secure links first so the CMSE import object exists for the NS link. log "build secure" - cargo build -p secure --release --target "${TARGET}" --locked + cargo build -p secure --release --target "${TARGET}" --locked "${feat[@]}" log "build nonsecure" - cargo build -p nonsecure --release --target "${TARGET}" --locked + cargo build -p nonsecure --release --target "${TARGET}" --locked "${feat[@]}" + # The boot stage is the immutable first stage and takes no feature. log "build boot-stage" cargo build -p boot-stage --release --target "${TARGET}" --locked [ -f "${BOOT_ELF}" ] && [ -f "${SECURE_ELF}" ] && [ -f "${NONSECURE_ELF}" ] \ diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 81f3ea4..6542a68 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -182,7 +182,7 @@ embedded_stage() # stays UNSET here so the target link-args from .cargo/config.toml (-Tlink.x) # survive: the warning gate rides on clippy (a check pass, no link) instead. local c - for c in platform mcu-arch mcu-spi mcu-flash image-verify fw-update tropic01-driver + for c in mcu-layout platform mcu-arch mcu-spi mcu-flash image-verify fw-update tropic01-driver do cargo clippy -p "$c" --locked --target thumbv8m.main-none-eabihf -- -D warnings || return 1 done