pureboot v9: seal every command, and stop guarding what the seal covers

'W' handed the loader a whole page with no ack inside it and sp_spm handed any
wire byte to SPMCSR, so a dropped byte re-aligned the stream and page data
arrived where commands belong. That is how a page-address byte became
BLBSET|SELFPRGEN on the tempmon board and programmed its lock bits.

The first answer was to refuse that one command. It was the wrong shape twice
over: it forbade a lock-bit write the owner may want, and it left every other
command decided by bytes nobody checked. v9 checks them instead. One header for
every command — opcode, selector, address, count, seal — folded and compared
before the command is decoded, and *answered* before any payload moves: '+'
accepts, 0xd4 (the ack inverted) refuses and nothing happened. An ack cannot do
this job; it reports a command that has already run.

It is smaller than v8 everywhere: 1284P 506→480, m8 498→480, 328P 484→468,
t13A 474→460. The seal costs 14 bytes; bit opcodes in place of the letters pay
for it twice over, since a letter costs a compare and a branch where a bit costs
a skip. Both guards go — the lock-bit refusal because the seal covers it, the
running-slot write guard because what it defended against was a wire fault
naming an address and a wire fault can no longer name one. That one is a real
trade: a host bug aimed at the running slot now lands. It buys a resident copy
that can write its own slot, which is the only self-update route on a chip whose
boot section *is* the slot.

Two things the tests caught, both introduced here. Removing the invalid-opcode
arm made every byte a command, so the knock stopped being harmless against a
loader already in session and ate the five bytes behind it — identify moves to
bit 5, which both 'p' and 'b' carry, so the knock is inert again and version
discovery still works before the version is known. And the SPM value rides the
count field because a data byte would arrive after the seal was checked.

pbselfwrite and pbglitch are the new gates, both red-green: the same erase of
the running page refused unsealed and performed sealed, and every header byte
damaged after sealing refused where the identical damage before sealing is
obeyed. Both judge by the simulator's flash, not the loader's opinion of it.
pbreloc and pbrehome lose their write-guard probes, which is what those two
gates replace. Defeating the seal in the loader turns seven tests red.

37 of 37 chips green with the exhaustive size matrix; README protocol section
and every size row rewritten. pbhw gains an adversarial --seal-rounds sweep for
the bench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 18:38:41 +02:00
parent eb213e1025
commit 546b1589a3
10 changed files with 761 additions and 213 deletions

124
test/pbglitch.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""A link that damages bytes, deliberately and reproducibly, against the seal
that exists for it.
The board this was written for loses and mangles bytes on its own serial path,
and the failure that made it matter — a page-fill byte lost, the stream one
byte out, a page-address byte arriving where an SPMCSR value belongs — is not
reachable by asking a healthy link nicely. So the damage is injected here, at
a named byte index rather than a probability: a failing case is a case that
fails again.
Every check is a pair. The same bit flipped in the same field is applied on
one side of the seal and then the other: *after* the host seals the header,
which is a mangled command and must be refused, and *before*, which is a
well-formed command for something else and must be obeyed. Only the pair
proves anything — a test that showed the refusal alone would pass against a
loader that had simply stopped doing SPM, and one that showed the corruption
alone would not say what caught it.
Usage: pbglitch.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <tool_py> <workdir>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def frame(pb, op, space, address, count, damage=None, before_seal=False):
"""A sealed command header, optionally with one byte damaged.
`damage` is (index, mask). Applied before the seal is computed it produces
a valid command for whatever the damaged fields now say; applied after, a
command whose seal no longer matches its own body — which is the shape a
link fault actually has."""
head = bytearray((op, pb.selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, count & 0xFF))
if damage and before_seal:
head[damage[0]] ^= damage[1]
seal = pb.SEAL
for byte in head:
seal ^= byte
out = bytearray(head + bytes((seal,)))
if damage and not before_seal:
out[damage[0]] ^= damage[1]
return bytes(out)
def main():
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
base, page, baud = int(base_hex, 0), int(page), int(baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "dump.bin")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
try:
port = pb.Port(device.pty, baud)
loader = pb.Loader(port)
info = loader.connect(25)
if info.version < pb.SEALED_LOADER:
fail(f"this test is for pureboot {pb.SEALED_LOADER} and later, not {info.version}")
# A page of known bytes to watch. Everything below aims at it, so
# "nothing happened" is a readable claim rather than an absence.
marker = bytes((0x40 + (i & 0x3F)) for i in range(page))
loader.write_page(0, marker)
if loader.read_flash(0, page) != marker:
fail("the marker page did not survive an undamaged write")
# Every field of the header, one bit each. A damaged seal must be
# refused, the loader must re-prompt, and the page must be untouched —
# and it is the erase being aimed at it, so a single escape is visible.
for index in range(6):
bad = frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE, damage=(index, 0x01))
port.write(bad)
answer = port.read_exact(1, 5.0)
if answer != pb.NAK:
fail(f"a header damaged in byte {index} was not refused (got {answer.hex()})")
if port.read_exact(1, 5.0) != pb.PROMPT:
fail(f"no prompt after refusing a header damaged in byte {index}")
if loader.read_flash(0, page) != marker:
fail(f"damage in byte {index} reached flash — the marker page changed")
# The fill, whose payload is the protocol's one unacked burst: a
# refused fill must be refused *before* the page is sent, or the host
# is left pushing 128 bytes into a loader reading commands. Nothing is
# sent after the verdict here, and the very next command must be
# understood — that is the whole claim.
bad = frame(pb, pb.OP_FILL, pb.SP_FLASH, 0, page, damage=(3, 0x80))
port.write(bad)
if port.read_exact(1, 5.0) != pb.NAK:
fail("a damaged fill header was not refused")
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("no prompt after refusing a damaged fill header")
if loader.identity().raw != info.raw:
fail("the loader was out of step after refusing a fill")
# The pair's other half. The identical flip, applied before the seal:
# a well-formed erase of the page one bit away from the one intended.
# It must be obeyed — otherwise the refusals above prove nothing about
# the seal and only that this loader stopped erasing.
port.write(frame(pb, pb.OP_WRITE, pb.SP_SPM, 0, pb.SPM_ERASE,
damage=(2, 0x01), before_seal=True))
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("a correctly sealed erase was refused")
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("no prompt after a correctly sealed erase")
if loader.read_flash(0, page) != b"\xff" * page:
fail("the sealed erase did not reach flash — the marker page is intact")
port.close()
finally:
device.stop()
print("pbglitch: damaged headers are refused, the identical damage sealed is obeyed")
main()

View File

@@ -3,11 +3,10 @@
canonical slot must still be a working loader, and the ordinary
--update-loader flow must put a build into the top slot from there.
Two positions. Address 0, a raw .bin handed to a programmer: the staging
install and the word-0 redirect run from copies outside page 0's slot, so the
running-slot guard never blocks them. And the staging slot itself, where a
loader already sitting there IS the staging copy — recognized by its embedded
block and left in place, then streaming the new resident like any staged copy.
Two positions. Address 0, a raw .bin handed to a programmer. And the staging
slot itself, where a loader already sitting there IS the staging copy —
recognized by its embedded block and left in place, then streaming the new
resident like any staged copy.
Usage: pbrehome.py <device_bin> <pureboot_elf> <update_bin> <mcu> <hz>
<base_hex> <page> <baud> <app_bin> <tool_py> <workdir>
@@ -22,7 +21,7 @@ def fail(message):
sys.exit(1)
def rehome_from(pbsim, pb, device_bin, elf, place_hex, guard_probe, update_bin, base, page, baud, app_bin, workdir,
def rehome_from(pbsim, pb, device_bin, elf, place_hex, update_bin, base, page, baud, app_bin, workdir,
mcu, hz):
"""Place the loader at `place_hex`, heal through --update-loader, flash
the application, expect the banner."""
@@ -38,14 +37,6 @@ def rehome_from(pbsim, pb, device_bin, elf, place_hex, guard_probe, update_bin,
if info.base != base:
fail(f"the misplaced copy reports base {info.base:#06x} — the info block must stay canonical")
# The accidental slot still guards itself; re-homing rides on the
# canonical slots being writable from it.
probe = int(guard_probe, 0)
before = loader.read_flash(probe, info.page)
loader.write_page(probe, bytes(info.page))
if loader.read_flash(probe, info.page) != before:
fail("the misplaced copy's guard let its own slot change")
# The ordinary update flow puts the build into the top slot.
pb.op_update_loader(loader, 25, update_bin, state, None)
update = open(update_bin, "rb").read()
@@ -76,16 +67,15 @@ def main():
os.makedirs(workdir, exist_ok=True)
# Address 0: the raw-.bin-to-a-programmer accident. The guard probe is
# the copy's own page 0.
rehome_from(pbsim, pb, device_bin, elf, "0x0", "0x0", update_bin, base, page, baud, app_bin, workdir, mcu, hz)
# Address 0: the raw-.bin-to-a-programmer accident.
rehome_from(pbsim, pb, device_bin, elf, "0x0", update_bin, base, page, baud, app_bin, workdir, mcu, hz)
print("re-home from address 0: converged")
# The staging slot: erased flash with the loader sitting exactly where
# a staging copy would — the tool must leave it in place and let it
# stream the (different) update build into the resident slot.
stage = base - pb.SLOT
rehome_from(pbsim, pb, device_bin, elf, hex(stage), hex(stage), update_bin, base, page, baud, app_bin, workdir,
rehome_from(pbsim, pb, device_bin, elf, hex(stage), update_bin, base, page, baud, app_bin, workdir,
mcu, hz)
print("re-home from the staging slot: converged")

View File

@@ -1,9 +1,8 @@
#!/usr/bin/env python3
"""Position-independence acceptance test: the identical binary, flashed one
slot below the resident, must serve the complete command set from there. The
info block must come back byte-identical, the write guard must refuse the
staged copy's own slot and permit the resident's, and the staged copy must be
able to rewrite the resident verbatim.
info block must come back byte-identical, and the staged copy must be able to
rewrite the resident verbatim — which is the whole of what relocation is for.
Usage: pbreloc.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <tool_py> <workdir>
@@ -64,14 +63,14 @@ def main():
if loader.read_eeprom(0, len(pattern)) != pattern:
fail("EEPROM round-trip through the staged copy")
# The guard, both ways: its own slot refused (drained, unchanged), the
# resident slot writable. The refusal leaves its drained words in the
# SPM buffer, so the write that follows may take them — and clears
# them by writing, so the retry must not.
before = loader.read_flash(stage, page)
loader.write_page(stage, bytes(page))
if loader.read_flash(stage, page) != before:
fail("the staged copy's guard let its own slot change")
# The resident slot, written from the copy standing beside it — the
# whole point of relocating. pureboot 9 dropped the running-slot guard
# that used to sit behind this, so the probe that used to accompany it
# (aim a write at the copy's *own* slot and watch it be refused) is
# gone with it: there is nothing to refuse now, and a copy that erases
# the page it is executing from does not come back to report it.
# pbselfwrite.py gates that direction on a device it is allowed to
# destroy.
marker = bytes((i * 3) & 0xFF for i in range(page))
loader.write_page(base, marker)
if loader.read_flash(base, page) != marker:

102
test/pbselfwrite.py Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""The seal, gated on the one command that proves it: erase the page the
loader is executing from.
pureboot 9 dropped the running-slot write guard, so this command is now
permitted — that is what lets a resident copy plant something in its own slot,
which on a chip whose boot section *is* the loader slot is the only route a
self-update has. Permitted means the loader must actually do it, and the only
honest proof is the flash afterwards.
What stands in the guard's place is the seal, and the two halves are tested
against each other here: the identical destructive command, refused when its
seal is wrong and honoured when it is right. A test that only showed the
refusal would pass just as well against a loader that ignores SPM entirely.
Usage: pbselfwrite.py <device_bin> <pureboot_elf> <mcu> <hz> <base_hex> <page>
<baud> <tool_py> <workdir>
"""
import os
import sys
def fail(message):
print(f"FAIL: {message}")
sys.exit(1)
def sealed_frame(pb, op, space, address, count):
"""A command header and its seal, built here rather than borrowed from the
tool: this test is about what the loader accepts, and a probe that shares
the host's frame builder cannot tell a wrong frame from a wrong loader."""
head = bytes((op, pb.selector(space, address), address & 0xFF,
(address >> 8) & 0xFF, count & 0xFF))
seal = pb.SEAL
for byte in head:
seal ^= byte
return head + bytes((seal,))
def main():
device_bin, elf, mcu, hz, base_hex, page, baud, tool, workdir = sys.argv[1:]
base, page, baud = int(base_hex, 0), int(page), int(baud)
sys.path.insert(0, os.path.dirname(os.path.abspath(tool)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import pbsim
import pureboot as pb
os.makedirs(workdir, exist_ok=True)
dump = os.path.join(workdir, "dump.bin")
device = pbsim.Device(device_bin, elf, mcu, hz, base_hex, page, baud, dump)
try:
port = pb.Port(device.pty, baud)
loader = pb.Loader(port)
info = loader.connect(25)
if info.version < pb.SEALED_LOADER:
fail(f"this test is for pureboot {pb.SEALED_LOADER} and later, not {info.version}")
# Erase the first page of the running slot: the entry stub and the
# command loop are both in it, so a loader that performs this does not
# answer again. Nothing else in the protocol is as sharp a probe.
frame = sealed_frame(pb, pb.OP_WRITE, pb.SP_SPM, base, pb.SPM_ERASE)
# Red: the same command with one bit wrong in its seal. Refused before
# anything happens, and the loader is still there to say so.
broken = bytearray(frame)
broken[-1] ^= 0x01
port.write(bytes(broken))
if port.read_exact(1, 5.0) != pb.NAK:
fail("an unsealed erase of the running page was not refused")
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("the loader did not re-prompt after refusing the erase")
alive = loader.read_flash(base, 8)
if alive == b"\xff" * 8:
fail("the refused erase happened anyway — the running page reads erased")
# Green: the identical command, correctly sealed. Nothing is required
# of the link from here on. The verdict is *issued* before the SPM, but
# the erase takes the code that would have finished saying it, and how
# much of it survives is the chip's business — an erase removes one page
# and nothing else, so a loader whose command loop lives past the page
# erased will prompt as usual where one with 128-byte pages goes with
# the stub. None of that is the claim. The claim is that the erase
# reached flash, and the dump is both the only witness for it and a
# better one: it tells "accepted and performed" from "merely answered".
port.write(frame)
try:
port.read_exact(2, 2.0)
except pb.Error:
pass
port.close()
finally:
device.stop()
# Ground truth: the simulator's flash, not the loader's opinion of it.
flash = open(dump, "rb").read()
if flash[base : base + page] != b"\xff" * page:
fail("the sealed erase did not reach flash — the running page is intact")
print("pbselfwrite: the running slot is refused unsealed and erased sealed")
main()

View File

@@ -131,19 +131,42 @@ def main():
fail(f"loader reports pureboot {live.version}, the tool speaks "
f"{pb.OLDEST_LOADER}..{pb.NEWEST_LOADER}")
# A W addressed inside a page rather than at its base must still
# A fill addressed inside a page rather than at its base must still
# consume exactly one page and prompt. The loader's own slot is the
# target the guard refuses to commit it — and the payload is
# erased-state bytes, so the probe can disturb neither the image nor
# the page buffer it leaves behind. Hand-built rather than through
# write_page(), which would follow the fill with its erase and
# write; the point here is that the fill alone consumes exactly one
# page whatever the address's low bits say.
# target and the payload is erased-state bytes, so the probe can
# disturb neither the image nor the page buffer it leaves behind: a
# fill only loads the buffer, and nothing commits it. Hand-built
# rather than through write_page(), which would follow the fill
# with its erase and write; the point here is that the fill alone
# consumes exactly one page whatever the address's low bits say.
# Hand-sealed too — a protocol probe that borrowed the tool's own
# frame builder could not tell a wrong frame from a wrong loader.
wire = base + 1
port.write(bytes((ord("W"), pb.selector(pb.SP_FLASH, wire), wire & 0xFF, (wire >> 8) & 0xFF))
+ b"\xff" * page)
head = bytes((pb.OP_FILL, pb.selector(pb.SP_FLASH, wire), wire & 0xFF,
(wire >> 8) & 0xFF, page & 0xFF))
seal = pb.SEAL
for byte in head:
seal ^= byte
port.write(head + bytes((seal,)))
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("unaligned W did not return to the prompt")
fail("the loader refused a correctly sealed fill")
port.write(b"\xff" * page)
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("unaligned fill did not return to the prompt")
# And the seal itself, red: one wrong bit in the address of that
# same frame must be refused outright. The verdict has to arrive
# *before* the page would have been sent — that ordering is what
# keeps a refusal from turning into a desync — so the probe sends
# no payload at all and expects the loader straight back at the
# command level.
broken = bytearray(head + bytes((seal,)))
broken[2] ^= 0x01
port.write(bytes(broken))
if port.read_exact(1, 5.0) != pb.NAK:
fail("a header with a broken seal was not refused")
if port.read_exact(1, 5.0) != pb.PROMPT:
fail("the loader did not re-prompt after refusing a broken seal")
loader.run_application()
banner = port.read_exact(3, 5.0)