'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>
103 lines
4.3 KiB
Python
103 lines
4.3 KiB
Python
#!/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()
|