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:
@@ -26,16 +26,20 @@ else:
|
||||
import termios
|
||||
|
||||
PROMPT = b"+"
|
||||
VERSION = 9 # this tool's own version — free to drift from a loader's
|
||||
# The refusal, from pureboot 9: the prompt inverted, so no single flipped bit
|
||||
# turns "nothing happened" into "it did".
|
||||
NAK = bytes((~PROMPT[0] & 0xFF,))
|
||||
VERSION = 10 # this tool's own version — free to drift from a loader's
|
||||
# The loader versions this tool can drive. A pureboot version implies its wire
|
||||
# protocol, which carries no number of its own, so this window is where that
|
||||
# map lives: the tool keeps a decoder for every generation in it (1–4 speak
|
||||
# the per-memory commands, 5 the unified pair; 6 marks the OSCCAL-carrying
|
||||
# builds and changes nothing on the wire; 8 the one-wire deployments, whose
|
||||
# only host-side trace is the --one-wire echo discard), and a version it has
|
||||
# no decoder for moves the floor.
|
||||
# only host-side trace is the --one-wire echo discard; 9 seals every command
|
||||
# and answers each seal before acting), and a version it has no decoder for
|
||||
# moves the floor.
|
||||
OLDEST_LOADER = 1
|
||||
NEWEST_LOADER = 8
|
||||
NEWEST_LOADER = 9
|
||||
SLOT = 512 # the loader slot, on every chip
|
||||
RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
|
||||
@@ -53,6 +57,30 @@ RETRIES = 3 # rewrites of a page that reads back wrong, before the run stops
|
||||
UNIFIED_LOADER = 5
|
||||
SP_FLASH, SP_EEPROM, SP_RAM, SP_FUSE, SP_SPM = 0, 1, 2, 3, 4
|
||||
|
||||
# pureboot 9 replaces the command letters with bits and seals every command.
|
||||
# The header is one shape for all of them — opcode, selector, address, count,
|
||||
# seal — and the loader answers the seal *before* it acts: PROMPT accepts, NAK
|
||||
# refuses and nothing happened. That verdict is what lets a refusal stay local
|
||||
# to its own command: the payload of a fill or a write burst only goes out
|
||||
# after the header has been accepted, so a rejected header never leaves the
|
||||
# host pushing bytes into a loader that has gone back to reading commands.
|
||||
#
|
||||
# A transfer is the absence of the other three opcodes, and its direction is
|
||||
# the low bit. Identify is bit 5 because both knock bytes carry it: 'b' has to
|
||||
# still ask the version (the host cannot know which generation it is talking to
|
||||
# until something answers), and 'p' has to stay harmless against a loader
|
||||
# already in session — pureboot 9 reserves no invalid opcode, so a knock that
|
||||
# meant nothing before would otherwise eat the five bytes behind it.
|
||||
SEALED_LOADER = 9
|
||||
OP_WRITE, OP_FILL, OP_JUMP, OP_IDENTIFY = 1, 4, 8, 0x20
|
||||
# What a sealed header's fields must fold to. Non-zero, so a run of one
|
||||
# repeated byte — a stuck line, a page of erased flash read as a header —
|
||||
# cannot satisfy it.
|
||||
SEAL = 0x5A
|
||||
# An SPM command carries its SPMCSR byte in the count field, where the seal
|
||||
# covers it. There is no data phase: a byte after the header would arrive after
|
||||
# the seal had already been checked, which is the hole the seal exists to close.
|
||||
|
||||
# An autobaud loader keeps its measured bit period readable, encoded as
|
||||
# delay-loop counts: (bit cycles − UNIT_DISCOUNT) / UNIT_LOOP_CYCLES,
|
||||
# floored — the spin granule and per-bit overhead of libavr's software UART.
|
||||
@@ -548,8 +576,8 @@ class Info:
|
||||
The base is where application flash ends, which is a property of the
|
||||
chip and not of the copy answering: a loader staged one slot lower
|
||||
reports the same geometry the resident one does, exactly as the loaders
|
||||
that send a block do. Which slot a copy runs in matters only to its own
|
||||
write guard, which is the loader's business."""
|
||||
that send a block do. Which slot a copy runs in is not something the
|
||||
identity reports, and from pureboot 9 nothing on the device cares."""
|
||||
if len(raw) != 4:
|
||||
raise Error(f"bad identity reply: {raw.hex()}")
|
||||
version, signature = raw[0], tuple(raw[1:4])
|
||||
@@ -669,6 +697,18 @@ class Loader:
|
||||
return Info(head + self.port.read_exact(8, 0.5))
|
||||
return Info.from_identity(head)
|
||||
|
||||
def identity(self):
|
||||
"""Ask a live session who it is.
|
||||
|
||||
The identity command takes no argument and changes nothing, which makes
|
||||
it the one question whose answer is known in advance — so it doubles as
|
||||
the host's check that the stream is still in step, and as the byte a
|
||||
lost host resynchronises on."""
|
||||
self.port.write(b"b")
|
||||
answer = self._read_identity()
|
||||
self._expect_prompt()
|
||||
return answer
|
||||
|
||||
def _handshake(self, wait, knock, what):
|
||||
"""One activation, retried until the loader answers or the window
|
||||
closes. The identity reply is what proves the loader is listening — a
|
||||
@@ -758,6 +798,32 @@ class Loader:
|
||||
if byte != PROMPT:
|
||||
raise Error(f"expected prompt, got {byte.hex()}")
|
||||
|
||||
@property
|
||||
def sealed(self):
|
||||
"""pureboot 9 and later: bit opcodes, a sealed header, and a verdict
|
||||
on that seal before the command runs."""
|
||||
return self.info is not None and self.info.version >= SEALED_LOADER
|
||||
|
||||
def _header(self, op, space, address, count, timeout=2.0):
|
||||
"""Send a sealed header and take the loader's verdict on it.
|
||||
|
||||
Returning normally means the loader has accepted the command and not
|
||||
yet done it — which is the whole point of the verdict, and why the
|
||||
payload of a fill or a write burst is sent only after this returns."""
|
||||
head = bytes((op, selector(space, address), address & 0xFF, (address >> 8) & 0xFF, count & 0xFF))
|
||||
seal = SEAL
|
||||
for byte in head:
|
||||
seal ^= byte
|
||||
self.port.write(head + bytes((seal,)))
|
||||
answer = self.port.read_exact(1, timeout)
|
||||
if answer == NAK:
|
||||
raise Error(
|
||||
f"the loader refused the command (opcode {op:#04x}, {address:#06x}): the "
|
||||
f"seal did not match, so nothing was done — the link mangled the header"
|
||||
)
|
||||
if answer != PROMPT:
|
||||
raise Error(f"expected a verdict on the seal, got {answer.hex()}")
|
||||
|
||||
def _command(self, tx, reply_len=0, timeout=2.0):
|
||||
self.port.write(tx)
|
||||
reply = self.port.read_exact(reply_len, timeout) if reply_len else b""
|
||||
@@ -785,9 +851,14 @@ class Loader:
|
||||
data = b""
|
||||
while count:
|
||||
chunk = min(count, 256, 0x10000 - (address & 0xFFFF))
|
||||
head = bytes((ord("G"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, chunk & 0xFF))
|
||||
data += self._command(head, chunk, 5.0)
|
||||
if self.sealed:
|
||||
self._header(0, space, address, chunk, 5.0)
|
||||
data += self.port.read_exact(chunk, 5.0)
|
||||
self._expect_prompt(5.0)
|
||||
else:
|
||||
head = bytes((ord("G"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, chunk & 0xFF))
|
||||
data += self._command(head, chunk, 5.0)
|
||||
address += chunk
|
||||
count -= chunk
|
||||
return data
|
||||
@@ -799,9 +870,11 @@ class Loader:
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
chunk = data[offset : offset + min(256, 0x10000 - (address & 0xFFFF))]
|
||||
head = bytes((ord("g"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, len(chunk) & 0xFF))
|
||||
self.port.write(head)
|
||||
if self.sealed:
|
||||
self._header(OP_WRITE, space, address, len(chunk))
|
||||
else:
|
||||
self.port.write(bytes((ord("g"), selector(space, address), address & 0xFF,
|
||||
(address >> 8) & 0xFF, len(chunk) & 0xFF)))
|
||||
for byte in chunk:
|
||||
self.port.write(bytes((byte,)))
|
||||
self._expect_prompt()
|
||||
@@ -813,7 +886,16 @@ class Loader:
|
||||
|
||||
def spm(self, operation, address):
|
||||
"""One SPM operation at a flash address — the erase, write and RWW
|
||||
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here."""
|
||||
re-enable that pureboot 4 ran inside 'W' and pureboot 5 leaves here.
|
||||
|
||||
From pureboot 9 the operation rides the header's count field instead of
|
||||
arriving as data behind it, which is what puts it inside the seal: the
|
||||
loader will not hand a byte to SPMCSR that the host did not seal, and
|
||||
the verdict says whether it did."""
|
||||
if self.sealed:
|
||||
self._header(OP_WRITE, SP_SPM, address, operation, 5.0)
|
||||
self._expect_prompt(5.0)
|
||||
return
|
||||
self._write_space(SP_SPM, address, bytes((operation,)))
|
||||
|
||||
def read_ram(self, address, count):
|
||||
@@ -851,12 +933,22 @@ class Loader:
|
||||
def write_page(self, address, data):
|
||||
assert len(data) == self.info.page and address % self.info.page == 0
|
||||
if self.unified:
|
||||
# 'W' fills the page buffer and stops there; the erase and the write
|
||||
# are host-issued SPM operations. Only a chip with a boot section
|
||||
# has RWW to re-enable — on the others bit 4 of SPMCSR means
|
||||
# something else entirely, so it must not be sent.
|
||||
head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF))
|
||||
self._command(head + data, 0, 2.0)
|
||||
# The fill loads the page buffer and stops there; the erase and the
|
||||
# write are host-issued SPM operations. Only a chip with a boot
|
||||
# section has RWW to re-enable — on the others bit 4 of SPMCSR
|
||||
# means something else entirely, so it must not be sent.
|
||||
if self.sealed:
|
||||
# The page goes out only once the header is accepted. It is the
|
||||
# protocol's one unacked burst, so a header refused after the
|
||||
# host had already started sending it would leave the page
|
||||
# being read as commands — which is exactly what the verdict
|
||||
# is placed here to prevent.
|
||||
self._header(OP_FILL, SP_FLASH, address, len(data) & 0xFF)
|
||||
self.port.write(data)
|
||||
self._expect_prompt(2.0)
|
||||
else:
|
||||
head = bytes((ord("W"), selector(SP_FLASH, address), address & 0xFF, (address >> 8) & 0xFF))
|
||||
self._command(head + data, 0, 2.0)
|
||||
self.spm(SPM_ERASE, address)
|
||||
self.spm(SPM_WRITE, address)
|
||||
if not self.info.patch_vector:
|
||||
@@ -891,8 +983,13 @@ class Loader:
|
||||
|
||||
def jump(self, word_address):
|
||||
"""The device acks, then execution continues at the word address.
|
||||
From v7 'J' rides the unified decode, so it carries a selector byte
|
||||
the loader ignores; older loaders take the bare address."""
|
||||
From v7 the jump rides the unified decode, so it carries a selector
|
||||
byte the loader ignores; older loaders take the bare address. From v9
|
||||
the ack is the verdict on its seal — a jump to a mangled address is a
|
||||
jump into arbitrary code, so it is sealed like everything else."""
|
||||
if self.sealed:
|
||||
self._header(OP_JUMP, 0, word_address, 0)
|
||||
return
|
||||
if self.info.version >= 7:
|
||||
self.port.write(bytes((ord("J"), 0, word_address & 0xFF, word_address >> 8)))
|
||||
else:
|
||||
@@ -1309,7 +1406,7 @@ def op_update_loader(loader, wait, path, state_path, fuse_bytes, staged_link=Non
|
||||
state.load_or_save(loader)
|
||||
|
||||
# A loader already sitting whole in the staging slot IS the staging copy:
|
||||
# rewriting it would only meet its own running-slot guard. Any pureboot
|
||||
# rewriting it in place would be a copy overwriting itself as it runs. Any pureboot
|
||||
# with the device's info block serves, since a staged copy only streams
|
||||
# pages. "Whole" needs both checks — the block where every image carries
|
||||
# it and matching byte for byte, and the slot unchanged since this update
|
||||
|
||||
Reference in New Issue
Block a user