Skip to content

Decide the boot chain offline: bootintel verdict - #17

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

Zenofex merged 1 commit into
mainfrom
feat/boot-chain-verdict

Conversation

@Zenofex

@Zenofex Zenofex commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

What

bootintel verdict <capture> reads a U-Boot printenv dump taken at the prompt and reports what the boot chain permits. Text, --json, and --gate-exposed for CI.

  exposed    Autoboot delay
      bootdelay is 2s, so anyone with console access gets 2s to take the prompt on every boot.
      read from bootdelay=2
      fix Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED).

Every other command reads a boot log, which is a record of what the firmware chose to print. This reads what an operator pulled out of a board after interrupting autoboot. A boot log can say autoboot looks interruptible; the environment says exactly what happens when you interrupt it, and whether you can change what boots.

Why offline

A U-Boot environment is the most sensitive thing in a capture: ipaddr, serverip, ethaddr, TFTP hosts, a client's internal addressing. Requiring an upload to learn what it permits would put this out of reach of exactly the people it is for. Applicability lookup stays server-side because the curated CVE ruleset is the asset that compounds; "bootdelay above zero means the prompt is reachable" is domain knowledge any practitioner already has.

Layout

File Role
crates/detectors/src/boot_chain.rs parse a session, decide what it permits
crates/cli/src/cmd/verdict.rs the command: text, JSON, --gate-exposed
crates/detectors/tests/boot_chain.rs parity against the shared expectation, plus the guards
crates/cli/tests/verdict_cli.rs exit codes and the JSON contract, driven as a process

Deliberately not a detector: the cross-implementation parity test asserts the Rust and browser detector sets match exactly at 14 labels, and a fifteenth would break it. No serde added to bootintel-detectors; the CLI serialises.

Drift control

The same rules now ship twice, here and in the bootintel.com engine, which is the exact problem the detector parity work just fixed. Neither side is the reference: tests/fixtures/boot_chain/expect.txt is, and bootintel.com holds a byte-identical copy that api/tests/test_boot_chain_parity.py asserts against. scripts/gen-boot-chain-expect.sh (in bootintel.com) is the only sanctioned way to change it and writes both copies in one go.

Two of the three fixtures are real boards with unrecognised vendor prompts (RTL8672 #), so the environment is only provable retroactively from the Environment size: line, and two values wrap across lines. That is the path a tokeniser rewrite breaks silently.

Discipline carried into the exit codes

  • 0 assessed, nothing exposed (with --gate-exposed)
  • 1 at least one exposed verdict, each named with the variable it was read from
  • 2 empty capture
  • 3 content, but no session in it. "Could not assess" must never look like "nothing is wrong".

Absence of a variable is unknown, never hardened: U-Boot prints only what is set. Device-controlled text is sanitised before it reaches a terminal, since a crafted environment value is an ANSI-injection vector into a consultant's session.

Verification

  • cargo test --workspace: 324 passed, 0 failed
  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo fmt --all --check: clean
  • Mutation-checked the parity test: a one-word wording change on the Rust side fails it, naming the first divergent line
  • Engine side merged and green in ci-api-regression (406 passed): BootIntel.com@0a9298ce

No version bump in this PR.

🤖 Generated with Claude Code

Every other command here reads a boot log, which is a record of what the
firmware chose to print. This reads what an operator pulled OUT of a board
after interrupting autoboot: a printenv dump. The difference is the whole
reason for taking the prompt. A boot log can say autoboot looks interruptible;
the environment says exactly what happens when you interrupt it, and whether
you can change what boots.

It runs entirely offline, unlike `scan --api`, and that is the point. A U-Boot
environment is the most sensitive thing in a capture (ipaddr, serverip,
ethaddr, TFTP hosts, a client's internal addressing), so requiring an upload to
learn what it permits would put this out of reach of exactly the people it is
for. Applicability lookup stays server-side because the curated CVE ruleset is
the asset that compounds; "bootdelay above zero means the prompt is reachable"
is domain knowledge any practitioner already has, and shipping it costs nothing.

  crates/detectors/src/boot_chain.rs   parse a session, decide what it permits
  crates/cli/src/cmd/verdict.rs        the command, text + JSON + --gate-exposed

Deliberately not a detector: the cross-implementation parity test asserts the
Rust and browser detector sets match exactly at 14 labels, and a fifteenth
would break it. The three parsing regexes are character-for-character the ones
in the engine, because keeping them literally identical is cheaper than
reasoning about whether two hand-written tokenisers agree. No serde added to
the detectors crate; the CLI serialises.

The cost of a second implementation is drift, which is the exact problem the
detector parity work just fixed. So a committed expectation file is the
reference for both, tests/fixtures/boot_chain/expect.txt here and a
byte-identical copy in bootintel.com, each repo asserting its own side against
it. Regenerated only by scripts/gen-boot-chain-expect.sh, which writes both.
Verified it fails on a one-word wording change (mutated the Rust side to check).

Exit codes carry the discipline the verdicts do: 3 when the capture holds no
session, because "could not assess" must never look like "nothing is wrong",
and absence of a variable is reported as unknown rather than hardened, because
U-Boot prints only what is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zenofex
Zenofex merged commit 2c19d8c into main Sep 28, 2026
11 checks passed
@Zenofex
Zenofex deleted the feat/boot-chain-verdict branch September 28, 2026 02:08
Zenofex added a commit that referenced this pull request Sep 28, 2026
## What

`bootintel analyze <port> --interrupt-autoboot` takes the U-Boot prompt,
pulls the environment, prints the boot-chain verdict, and hands the
terminal back. This closes the loop #17 opened: capture to verdict with
no typing and no byte leaving the machine.

```
[bootintel] ▸  hammering space now: POWER-CYCLE THE BOARD. The key has to be waiting in
               the UART before U-Boot looks, so this only works if the reset happens
               after this line.
[bootintel] ▸  prompt reached: "=>". Autoboot was interrupted.
[bootintel] ▸  typing `printenv`
[bootintel] ▸  environment captured. The prompt is yours; the verdict is below.

  exposed    Image verification
      verify is disabled, so U-Boot will not check image checksums before booting.
      read from verify=no
```

## Why it is not "watch for the countdown, then send a key"

That is the obvious design and it does not work. The autoboot delay is a
loop around `tstc()`, and `bootdelay=0` means the check happens
**once**. Boards ship that way. By the time the countdown has crossed
the wire, been read by this process and been recognised, the window is
gone: at 115200 a character is already ~87us of wire time, and the read
is scheduled by the OS, not by us.

What works is that the byte is already in the UART's receive register
when the board looks. So this hammers from the instant the port opens
and asks the operator to power-cycle *after* that starts. There is
nothing to detect, which is the point. `--reset-line dtr|rts` removes
human timing entirely, where the adapter is wired to the board's reset.

## Split by requirement, not by layer

| Part | Requirement | Where |
| --- | --- | --- |
| The hammer | must be FAST | a thread that writes one byte string on a
timer and holds no logic at all |
| The decisions | must be RIGHT | `src/term/autoboot.rs`: a pure state
machine over received bytes, no clock, no I/O |

Which is what makes the timing claim testable. `timing_tests` simulates
a board whose check fires once, at seven instants from 1ms to 900ms:
caught every time. The contrast test arms the hammer only after the
countdown is visible (the design this replaces) and misses.

## Safety, on a client's only sample of a device

- The hammer **never sends CR or LF**. Refused when the key is parsed,
asserted in tests: hammered bytes accumulate in U-Boot's line buffer and
a newline would execute whatever they spell.
- Default key is a space; default commands are read-only (`printenv`,
`bdinfo`, `mtdparts`). `--at-prompt` replaces that set entirely, so the
operator decides exactly what is typed.
- A bare CR flushes the accumulated bytes before anything is typed.
- Every byte the tool sends is announced, so a client transcript shows
which bytes were the tool's.
- Tuning flags without `--interrupt-autoboot` are refused rather than
ignored: someone who passed `--interrupt-key stop` and got a plain
terminal would reasonably think the tool tried.

## Two false-positive guards

- A `#` prompt **after the kernel handoff** is a Linux shell. It is
ignored and hammering continues until a fresh bootloader banner clears
the latch. Typing `printenv` into a root shell would have produced a
confident, wrong verdict.
- A prompt that **does not answer** re-arms rather than abandoning the
attempt. `Loading: #` mid-transfer and a board that resets under us are
both cheaper to retry than to give up on.

## Verification

- `cargo test --workspace`: 349 passed, 0 failed (was 324). `clippy -D
warnings` and `fmt --check` clean.
- End-to-end against a socat PTY pair and a simulated `bootdelay=0`
board: caught the single check, ran all three commands, printed the
verdict. The board saw only those three commands despite 594 buffered
spaces and a stray `0x04` from the harness, which is the CR flush doing
its job.
- The missed-window path was verified too, by getting the harness
ordering wrong first: it reported the miss with the four things worth
checking and never implied success.

Not wired into `--tui` yet; that combination is refused rather than
silently ignored. No version bump.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex added a commit that referenced this pull request Sep 28, 2026
Version bump, lockfile, changelog, and one docs fix. No code changes:
everything in this release is already on `main` and was reviewed in #17
and #18.

## What ships

| | |
| --- | --- |
| #17 | `bootintel verdict <capture>` — assess a U-Boot session offline
|
| #18 | `analyze --interrupt-autoboot` — take the prompt and pull the
environment |

Together: bench to verdict with no upload and no typing.

## Why MINOR

Two new subcommands' worth of surface and no behaviour change for anyone
who does not use them. The policy at the top of `CHANGELOG.md` reserves
PATCH for changes with no user-visible behaviour change, and MAJOR for
renamed flags, schema changes, or exit-code policy changes.

## The one non-mechanical change

`crates/cli/Cargo.toml` pins `bootintel-detectors` by exact version,
because the published binary crate has to name a version that exists on
the index. That bump is part of cutting a release rather than something
cargo derives, and forgetting it fails `cargo update -w` immediately, so
it cannot reach a release quietly. It is still the step that gets
missed, so `docs/releasing.md` step 1 now names it.

## Verification

- `cargo test --workspace`: 349 passed, 0 failed
- `cargo clippy --workspace --all-targets -- -D warnings`: clean
- `cargo fmt --all --check`: clean
- `cargo build --release` then `bootintel --version` reports `bootintel
0.8.0`

After merge: dispatch `cli-release` for `0.8.0` with `publish_crates`,
verify the draft against `SHA256SUMS`, publish and mark latest, then
move the Homebrew formula.

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Zenofex added a commit that referenced this pull request Sep 28, 2026
Follows #17/#18. The engine learned to read what the bootloader actually
verified
([BootIntel.com@fd96903](BootIntel/BootIntel.com@fd969038));
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](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