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