Skip to content

Port boot_integrity to Rust, and pin it with two real fixtures - #21

Merged
Zenofex merged 1 commit into
mainfrom
feat/boot-integrity
Sep 28, 2026
Merged

Zenofex merged 1 commit into
mainfrom
feat/boot-integrity

Conversation

@Zenofex

@Zenofex Zenofex commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Follows #17/#18. The engine learned to read what the bootloader actually verified (BootIntel.com@fd96903); until the CLI does too, the offline verdict says "I cannot tell whether images are checked" about captures that answer the question themselves, and the two implementations disagree about the same device.

What the CLI now reports

  confirmed  Image verification
      The bootloader checked the image before booting it, using a legacy uImage CRC,
      and the check passed. That proves the image was not corrupt. It is not a
      signature: anyone who can write the image can recompute the checksum, so this
      stops bit-rot rather than an attacker.
      read from Verifying Checksum ... OK

  exposed    Secure boot anchor
      The SoC reports the HAB fuse is not enabled, so the boot ROM will run an
      unsigned image. Whatever the bootloader does about checksums afterwards is
      advisory: the chain has no anchor.
      read from hab fuse not enabled

A passing checksum is confirmed, never hardened. Only a verified signature (sha256,rsa2048:dev+ OK) is hardened. The anchor entry is emitted without needing a printenv, since the fuse fact does not depend on one.

The fixtures are the point

Neither is synthetic:

Fixture Source What it pins
hab-unblown.log bootintel-7 lines 1-45 prompt reached with no environment, unblown HAB fuse, and a harness line quoting 'Bad Linux ARM64 Image magic!' that must not trip the anchored failure pattern
verified-image.log bootintel-20 lines 55-160 environment provable only retroactively, plus a failed check (Bad Magic Number, from an operator's iminfo) and a passed one in that order

Both implementations reproduce expect.txt byte for byte across five fixtures. That is the first time the guarantee has covered anything but a synthetic capture and two environments.

A bug found while porting

The engine's signature pattern included ## Checking (hash|sign), and U-Boot's ordinary FIT output is ## Checking hash(es) for FIT Image at .... Every device using unsigned FIT hashes would have been reported as having had a signature verified, and the verdict would have told a client authenticity was established. No corpus log prints that line, which is exactly why it survived review: absence of evidence read as correctness. The real tell is the algorithm list, so that is what both sides key on now, with a test for each shape.

Breaking, library only

boot_chain::assess returns an Assessment { session, integrity, verdicts } instead of a tuple, and verdict takes the integrity alongside the session. The alternative was a second entry point for the same operation; two functions differing only in how much they tell you is worse for whoever reads it next. No CLI flag, output or exit code changed. verdict --json gains a boot_integrity object mirroring the engine's key names, omitting fields the capture said nothing about rather than emitting nulls.

Under the policy at the top of CHANGELOG.md that puts the next release at 0.9.0.

Verification

  • cargo test --workspace: 349 passed, 0 failed
  • clippy --workspace --all-targets -- -D warnings and fmt --check: clean
  • Engine side green at 418 passed in ci-api-regression, pushed and deployed
  • Ran the built binary against both new fixtures

🤖 Generated with Claude Code

The engine learned to read what the bootloader actually verified. Until the CLI
does too, the offline verdict says "I cannot tell whether images are checked"
about captures that answer the question on their own, and the two
implementations disagree about the same device, which is the failure the shared
expectation file exists to prevent.

So this ports it, and the fixtures are the point rather than an afterthought.
Neither new fixture is synthetic:

  hab-unblown.log    bootintel-7 lines 1-45. Reaches the prompt via
                     `u-boot=> setenv factorymode 1`, never dumps the
                     environment, and reports `hab fuse not enabled`. It pins
                     that integrity facts are read WITHOUT a printenv, and that a
                     harness line quoting U-Boot error strings
                     ('Bad Linux ARM64 Image magic!') does not trip the anchored
                     failure pattern.
  verified-image.log bootintel-20 lines 55-160. Environment provable only
                     retroactively from `Environment size:`, and the capture
                     carries a FAILED check (`Bad Magic Number`, from an
                     operator's `iminfo`) and a PASSED one
                     (`Verifying Checksum ... OK`) in that order. Both recorded,
                     which is the honest reading of a capture holding both.

Both implementations now reproduce the expectation byte for byte across five
fixtures, which is the first time the guarantee has covered anything but a
synthetic capture and two environments.

One bug found while porting, in code already shipped on the engine side. The
signature pattern included `## Checking (hash|sign)`, and U-Boot's ordinary FIT
output is `## Checking hash(es) for FIT Image at ...`. Every device using
UNSIGNED FIT hashes would have been reported as having had a signature verified,
and the verdict would have told a client authenticity was established. No corpus
log prints that line, which is exactly why it survived: absence of evidence read
as correctness. The real tell is the algorithm list, which reads
`sha256,rsa2048:dev+ OK` when a signature node was checked, so that is what is
keyed on now.

The renderer behind the expectation was also duplicated between the generator
and the test that checks it. Two renderers behind a file whose entire job is to
prove two implementations agree is the same mistake one level up; it now lives in
`analysis_engine/parity_render.py` and both import it. The Rust mirror stays a
mirror on purpose: the claim is that two independent implementations agree, which
sharing code would quietly stop testing.

349 tests, clippy clean under -D warnings, rustfmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zenofex
Zenofex merged commit 1b72c81 into main Sep 28, 2026
11 checks passed
@Zenofex
Zenofex deleted the feat/boot-integrity branch September 28, 2026 10:45
Zenofex added a commit that referenced this pull request Sep 28, 2026
…nment (#22)

Version bump, lockfile, changelog. No code changes: everything shipping
here is already on `main` and was reviewed in #21.

## What ships

The boot-chain verdict stops hedging when the capture answers the
question itself. A log containing `Verifying Checksum ... OK` no longer
gets "bootcmd boots an image without a visible verification step" while
the visible step sits twenty lines away. New `Secure boot anchor` entry
for an unblown i.MX HAB fuse, emitted without needing a `printenv` since
the fact doesn't depend on one.

What it still won't do is call a checksum a signature: a passing CRC is
`confirmed`, never `hardened`.

Both implementations now reproduce `expect.txt` byte for byte across
five fixtures, two of them real corpus captures.

## Why MINOR

Either reason alone is enough:

- New verdict behaviour.
- **Breaking library API**: `boot_chain::assess` returns an `Assessment
{ session, integrity, verdicts }` instead of a `(UbootSession,
Vec<Verdict>)` tuple, and `verdict` takes the integrity alongside the
session.

The policy at the top of `CHANGELOG.md` puts a pre-1.0 breaking change
on the minor. **No CLI flag, output or exit code changed**, so nothing
changes for anyone using the binary.

## The step that used to get missed

`crates/cli/Cargo.toml` pins `bootintel-detectors` by exact version and
cargo does not derive it. Both bumps landed first try because
`docs/releasing.md` step 1 now names it — added in #19 after it bit the
0.8.0 cut.

## Verification

- `cargo test --workspace`: 355 passed, 0 failed
- `clippy --workspace --all-targets -- -D warnings` and `fmt --all
--check`: clean
- `cargo build --release` then `bootintel --version` reports `bootintel
0.9.0`
- Ran the release binary against the new fixtures

After merge: dispatch `cli-release` for `0.9.0` with `publish_crates`,
verify the draft against `SHA256SUMS`, publish and mark latest, then
move the tap (step 7) and sync the in-repo reference copy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant