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);