4"""Pre-flash IMAGE guard: refuse a firmware image that would brick recovery.
6Owner policy (2026-07-23): a bad image must never be able to permanently
7disable device recovery on the RA8D2 -- not even one that came from outside
8this tree. This scanner inspects the ACTUAL bytes of the ``.hex`` / ``.elf``
9about to be programmed and REFUSES it if it writes a lockdown value into the
10anti-recovery option-setting / security / OTP region.
14A *programmed* (non-erased) value landing in the permanent security / OTP
15sub-region of the option-setting memory. On the RA8D2 the option-setting
16memory (HUM Ch 7 "Option-Setting Memory") is erased to all-ones; a real
17application leaves every one of these words at ``0xFFFFFFFF``. A byte that is
18NOT ``0xFF`` in one of these windows means the image is trying to *set* it:
20 * First-Stage Boot Loader control (FSBLCTRL) -- boot lock.
21 * MRAM/secure attribution + access control (SAMR / SACC).
22 * PERMANENT block protect (PBPS / PBPS_SEC) -- irreversible.
23 * HUK zeroize (ZHUK) -- destroys the device-unique key.
24 * The extra-MRAM OTP window (FSBL / code cert / PBPS / POFSPS / REVOKE /
25 HUK-zeroize / anti-rollback, HUM Ch 59.7.4.5 Table 59.15) and the
26 anti-rollback counters (ARCCS / ARC_SEC / ARC_NSEC).
28None of the project's firmware programs any of these -- verified: every app's
29``.option_setting_otp_*`` word is ``0xFFFFFFFF`` and no example overrides the
30default. So any non-erased value there is, by construction, not something this
31project produces, and the safe answer is to refuse (this is the conservative
32policy the owner asked for: the firmware never touches the lockdown region, so
33ANY programmed data there is rejected).
35What it ALLOWS (must not be flagged)
36------------------------------------
37 * Normal code / rodata / vectors / data anywhere in MRAM, SRAM, or external
39 * The benign option bytes every app legitimately sets, AT ANY VALUE: OFS0 /
40 OFS1 / OFS2 / OFS3 (watchdog, LVD, HOCO, extended clocks), SAS + the
41 OFS*_SEC / OFS*_SEL TrustZone attribution selectors, and BPS / BPS_SEC
42 block-protect for normal use. (e.g. ``bkup_survival_demo`` legitimately
43 programs ``OFS1 = 0xFFFFFFF0`` to enable LVD0 -- that is allowed.)
44 * Any word in the anti-recovery region that is still erased (``0xFFFFFFFF``)
45 -- a real image carries those sections erased.
47The addresses use the flash-programming view of the option-setting memory
48(``0x0300A000`` block) -- the addresses that actually appear in a linked
49``.hex`` / ``.elf`` (confirmed against a built ``uart_hello.elf``) -- plus the
50extra-MRAM / config-set / anti-rollback windows a lockdown could otherwise
55 check_image_no_antirecovery.py IMAGE.hex # or IMAGE.elf
56 check_image_no_antirecovery.py --selftest # prove both directions
58Exit 0 when the image is safe to flash, 1 when it must be refused (or on a
59bad/unreadable image, or a failing selftest).
62from __future__
import annotations
70from pathlib
import Path
77IHEX_MIN_RECORD_LEN = 5
80IHEX_EXT_SEG_ADDR = 0x02
81IHEX_START_SEG_ADDR = 0x03
82IHEX_EXT_LIN_ADDR = 0x04
83IHEX_START_LIN_ADDR = 0x05
92ANTI_RECOVERY_WINDOWS: tuple[tuple[str, int, int, str], ...] = (
94 "OTP security/permanent block (FSBLCTRL/SAMR/SACC/PBPS/ZHUK)",
97 "HUM Ch 7 Option-Setting Memory p 278-299",
100 "extra-MRAM OTP window (FSBL/cert/PBPS/POFSPS/REVOKE/HUK-zeroize)",
103 "HUM Ch 59.7.4.5 Table 59.15 p 3592",
106 "OFS config-set (MACI) window",
109 "HUM Ch 7 Option-Setting Memory p 278",
112 "anti-rollback counters (ARCCS/ARC_SEC/ARC_NSEC)",
115 "HUM Ch 7.2.21-7.2.23 p 296-297",
119MAX_FINDINGS_SHOWN = 40
122def _objcopy() -> str:
123 """Return the arm-none-eabi-objcopy path, or fail loudly if it is absent.
125 An image guard that silently skipped the ELF because a tool was missing
126 would be a guard that passes a brick image -- so this fails rather than
127 degrading to a no-op (CLAUDE.md: gates fail loudly on a missing tool).
130 os.environ.get(
"RA8_OBJCOPY",
""),
131 "arm-none-eabi-objcopy",
134 resolved = shutil.which(cand)
if cand
else None
135 if resolved
is not None:
138 "check_image_no_antirecovery.py: FATAL -- no objcopy found to read an ELF.\n"
139 " Install arm-none-eabi-objcopy or set RA8_OBJCOPY=/path/to/objcopy.\n"
140 " Refusing to skip the ELF: a skipped image check is a brick waiting to happen."
144def parse_ihex(text: str) -> dict[int, int]:
145 """Parse Intel HEX text into an ``{address: byte}`` map.
147 Handles record types 00 (data), 01 (EOF), 02 (extended segment), 04
148 (extended linear address) and 05 (start linear). The extended-address
149 records are essential: a linked RA8D2 image sits at 0x0200_0000 and
150 0x0300_A000, so without type-04 handling the option bytes would be read at
151 the wrong address and silently missed.
153 mem: dict[int, int] = {}
155 for raw_line
in text.splitlines():
156 line = raw_line.strip()
157 if not line
or not line.startswith(
":"):
160 rec = bytes.fromhex(line[1:])
162 sys.exit(f
"check_image_no_antirecovery.py: FATAL -- malformed HEX record: {line!r}")
163 if len(rec) < IHEX_MIN_RECORD_LEN
or (sum(rec) & 0xFF) != 0:
164 sys.exit(f
"check_image_no_antirecovery.py: FATAL -- bad HEX record: {line!r}")
165 length, addr_hi, addr_lo, rectype = rec[0], rec[1], rec[2], rec[3]
166 data = rec[4 : 4 + length]
167 if rectype == IHEX_DATA:
168 start = base + ((addr_hi << 8) | addr_lo)
169 for i, byte
in enumerate(data):
170 mem[start + i] = byte
171 elif rectype == IHEX_EXT_LIN_ADDR:
172 base = ((data[0] << 8) | data[1]) << 16
173 elif rectype == IHEX_EXT_SEG_ADDR:
174 base = ((data[0] << 8) | data[1]) << 4
175 elif rectype
in (IHEX_EOF, IHEX_START_SEG_ADDR, IHEX_START_LIN_ADDR):
180def load_image(path: Path) -> dict[int, int]:
181 """Load a ``.hex`` or ``.elf`` firmware image into an ``{address: byte}`` map.
183 ``.hex`` is parsed directly. ``.elf`` is converted to Intel HEX with
184 objcopy first, so load (LMA) addresses -- what actually gets programmed --
185 are used, not the VMA.
187 suffix = path.suffix.lower()
188 if suffix
in (
".hex",
".ihex",
".mot",
""):
189 return parse_ihex(path.read_text(encoding=
"latin-1"))
190 if suffix
in (
".elf",
".axf",
".out"):
192 with tempfile.TemporaryDirectory()
as td:
193 out = Path(td) /
"image.hex"
194 proc = subprocess.run(
195 [objcopy,
"-O",
"ihex", str(path), str(out)],
200 if proc.returncode != 0:
202 f
"check_image_no_antirecovery.py: FATAL -- objcopy failed on {path}:\n"
205 return parse_ihex(out.read_text(encoding=
"latin-1"))
207 return parse_ihex(path.read_text(encoding=
"latin-1"))
210def scan_memory(mem: dict[int, int]) -> list[dict]:
211 """Return every non-erased byte that lands in an anti-recovery window."""
212 findings: list[dict] = []
213 for addr, byte
in sorted(mem.items()):
216 for name, start, end, cite
in ANTI_RECOVERY_WINDOWS:
217 if start <= addr <= end:
219 {
"addr": addr,
"byte": byte,
"window": name,
"start": start,
"cite": cite}
225def _report(image: str, findings: list[dict]) ->
None:
226 """Explain which lockdown writes were found and why the flash is refused."""
228 f
"check_image_no_antirecovery.py: REFUSING to flash {image}\n"
229 f
" {len(findings)} byte(s) program a lockdown value into the anti-recovery\n"
230 f
" option-setting / security / OTP region:\n",
233 for f
in findings[:MAX_FINDINGS_SHOWN]:
235 f
" 0x{f['addr']:08X} = 0x{f['byte']:02X} [{f['window']}] ({f['cite']})",
238 if len(findings) > MAX_FINDINGS_SHOWN:
239 print(f
" ... {len(findings) - MAX_FINDINGS_SHOWN} more (truncated)", file=sys.stderr)
241 "\nOwner policy (2026-07-23): this project must NEVER permanently disable\n"
242 "device recovery. No first-party image programs this region -- every\n"
243 "option-setting OTP word ships erased (0xFFFFFFFF). A non-erased value\n"
244 "here is a boot-lock / permanent-block-protect / HUK-zeroize / anti-\n"
245 "rollback lockdown, which the recovery scripts cannot undo.\n"
246 "\nBenign option bytes (OFS0/OFS1/OFS2/OFS3, SAS, OFS*_SEC/SEL, BPS) are\n"
247 "allowed at any value; only the security/OTP lockdown region is refused.\n"
248 "\nIf you are DELIBERATELY provisioning a board and accept the brick risk,\n"
249 "re-run with RA8_ALLOW_ANTIRECOVERY_FLASH=1 (loud, explicit override).",
257def _ihex_record(rectype: int, addr16: int, data: list[int]) -> str:
258 """Build one Intel HEX record line (with checksum)."""
259 body = [len(data), (addr16 >> 8) & 0xFF, addr16 & 0xFF, rectype, *data]
260 checksum = (-sum(body)) & 0xFF
261 return ":" +
"".join(f
"{b:02X}" for b
in [*body, checksum])
264def _ela(upper16: int) -> str:
265 """Extended-linear-address record setting the upper 16 bits of the address."""
266 return _ihex_record(0x04, 0, [(upper16 >> 8) & 0xFF, upper16 & 0xFF])
269def _word_le(value: int) -> list[int]:
270 """Little-endian 4-byte list for a 32-bit word."""
271 return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF]
274def selftest() -> int:
275 """Assert the scanner refuses lockdown images and passes benign ones.
277 Both directions: a synthetic image that programs the disable-initialize /
278 permanent-lock OTP region is REFUSED, and one that carries only code plus
279 benign (even non-erased) OFS bytes plus erased OTP words is ALLOWED.
281 eof = _ihex_record(0x01, 0, [])
288 _ihex_record(0x00, 0x0000, _word_le(0x20001000)),
290 _ihex_record(0x00, 0xA800, _word_le(0x00000000)),
291 _ihex_record(0x00, 0xA900, _word_le(0x00000000)),
299 _ihex_record(0x00, 0x7600, _word_le(0x00000000)),
308 _ihex_record(0x00, 0x0000, _word_le(0x20001000)),
309 _ihex_record(0x00, 0x0200, [0x00, 0xBF, 0x00, 0xBF]),
311 _ihex_record(0x00, 0xA104, _word_le(0xFFFFFFF0)),
312 _ihex_record(0x00, 0xA800, _word_le(0xFFFFFFFF)),
313 _ihex_record(0x00, 0xA900, _word_le(0xFFFFFFFF)),
318 cases: list[tuple[str, str, bool]] = [
319 (
"lockdown: PBPS + ZHUK programmed", bad,
True),
320 (
"lockdown: extra-MRAM OTP programmed", bad2,
True),
321 (
"benign: code + OFS1=0xFFFFFFF0 + erased OTP", good,
False),
323 failures: list[str] = []
324 for label, text, should_refuse
in cases:
325 refused = bool(scan_memory(parse_ihex(text)))
326 if refused != should_refuse:
327 verb =
"did not refuse" if should_refuse
else "refused"
328 failures.append(f
" {label}: scanner {verb} (unexpected)")
331 print(
"check_image_no_antirecovery.py --selftest: FAILED\n", file=sys.stderr)
332 print(
"\n".join(failures), file=sys.stderr)
334 refused_n = sum(1
for c
in cases
if c[2])
336 f
"check_image_no_antirecovery.py --selftest: PASS "
337 f
"({len(cases)} cases: {refused_n} must refuse, {len(cases) - refused_n} must allow)"
342def main(argv: list[str]) -> int:
343 """Scan a firmware image for anti-recovery lockdown writes, or run the selftest.
345 Returns 0 when the image is safe to flash, 1 when it must be refused (a
346 lockdown value in the security/OTP region), on an unreadable image, or on a
349 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
350 ap.add_argument(
"image", nargs=
"?", help=
"path to a .hex or .elf image")
351 ap.add_argument(
"--selftest", action=
"store_true", help=
"prove both directions")
352 args = ap.parse_args(argv[1:])
358 ap.error(
"an image path is required (or use --selftest)")
359 path = Path(args.image)
360 if not path.is_file():
361 print(f
"check_image_no_antirecovery.py: FATAL -- image not found: {path}", file=sys.stderr)
364 mem = load_image(path)
365 findings = scan_memory(mem)
368 f
"check_image_no_antirecovery.py: OK ({path} -- "
369 f
"{len(mem)} bytes, no anti-recovery lockdown writes)"
373 _report(str(path), findings)
377if __name__ ==
"__main__":
378 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.