Skip to content

Interrupt autoboot by being early, not by being clever - #18

Merged
Zenofex merged 1 commit into
mainfrom
feat/interrupt-autoboot
Sep 28, 2026
Merged

Zenofex merged 1 commit into
mainfrom
feat/interrupt-autoboot

Conversation

@Zenofex

@Zenofex Zenofex commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

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

`analyze --interrupt-autoboot` takes the U-Boot prompt, pulls the environment,
prints the boot-chain verdict, and hands the terminal back. That completes the
loop `bootintel verdict` opened: capture to verdict without the operator typing
anything, and without a byte leaving the machine.

The obvious implementation does not work, so this does not do it. U-Boot's
autoboot delay is a loop around `tstc()`, and `bootdelay=0` means the check
happens ONCE. Boards ship that way. By the time "Hit any key to stop autoboot"
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: detection is the part that is too slow.
`--reset-line dtr|rts` removes human timing entirely where the adapter is wired
for it.

Split by requirement rather than by layer:

  * the hammer is a thread that writes one byte string on a timer and holds no
    logic at all, because it is the only part that has to be fast;
  * every decision is a pure state machine over the received bytes with no
    clock and no I/O, because that is the part that has to be right, and it can
    then be tested against a simulated board instead of a bench.

`timing_tests` runs that simulation: a board whose check fires once, at seven
different 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, because this runs against a client's only sample of a device. The
hammer never sends CR or LF (refused when the key is parsed, and asserted):
hammered bytes accumulate in U-Boot's line buffer and a newline would execute
whatever they spell. The default key is a space, the default commands are
read-only, a bare CR flushes the accumulated bytes before anything is typed,
and every byte the tool sends is announced so a client transcript shows which
were the tool's.

Two false-positive guards, both from thinking about what actually appears on a
UART. A `#` prompt after the kernel handoff is a Linux shell, so 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. And a prompt that does not answer re-arms rather than abandoning the
attempt, since `Loading: #` mid-transfer and a board that resets under us are
both cheaper to retry than to give up on.

Verified 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, and the
board saw only those three commands despite 594 buffered spaces and a stray
0x04 from the harness. 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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zenofex
Zenofex merged commit 62644c5 into main Sep 28, 2026
11 checks passed
@Zenofex
Zenofex deleted the feat/interrupt-autoboot branch September 28, 2026 06:26
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