ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_image_no_antirecovery.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Pre-flash IMAGE guard: refuse a firmware image that would brick recovery.
5
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.
11
12What it refuses
13---------------
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:
19
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).
27
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).
34
35What it ALLOWS (must not be flagged)
36------------------------------------
37 * Normal code / rodata / vectors / data anywhere in MRAM, SRAM, or external
38 memory.
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.
46
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
51target.
52
53Run::
54
55 check_image_no_antirecovery.py IMAGE.hex # or IMAGE.elf
56 check_image_no_antirecovery.py --selftest # prove both directions
57
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).
60"""
61
62from __future__ import annotations
63
64import argparse
65import os
66import shutil
67import subprocess
68import sys
69import tempfile
70from pathlib import Path
71
72# The erased state of option-setting / OTP flash: all-ones. A byte that is not
73# this is a programmed bit.
74ERASED = 0xFF
75
76# Intel HEX record layout + record types (Intel HEX specification).
77IHEX_MIN_RECORD_LEN = 5 # length + addr(2) + type + checksum
78IHEX_DATA = 0x00
79IHEX_EOF = 0x01
80IHEX_EXT_SEG_ADDR = 0x02
81IHEX_START_SEG_ADDR = 0x03
82IHEX_EXT_LIN_ADDR = 0x04
83IHEX_START_LIN_ADDR = 0x05
84
85# Anti-recovery windows: (name, start, end_inclusive, hum_cite). A non-erased
86# byte anywhere in one of these is a lockdown attempt and the image is refused.
87#
88# These are the permanent / security / OTP structures. The benign option words
89# (OFS0-3 at 0x0300A100-0x0300A113, SAS, the OFS*_SEC/SEL selectors at
90# 0x0300A200-0x0300A213, and BPS/BPS_SEC at 0x0300A300/0x0300A400) are
91# deliberately NOT here -- apps set those legitimately.
92ANTI_RECOVERY_WINDOWS: tuple[tuple[str, int, int, str], ...] = (
93 (
94 "OTP security/permanent block (FSBLCTRL/SAMR/SACC/PBPS/ZHUK)",
95 0x0300A500,
96 0x0300A9FF,
97 "HUM Ch 7 Option-Setting Memory p 278-299",
98 ),
99 (
100 "extra-MRAM OTP window (FSBL/cert/PBPS/POFSPS/REVOKE/HUK-zeroize)",
101 0x02E07600,
102 0x02E179FF,
103 "HUM Ch 59.7.4.5 Table 59.15 p 3592",
104 ),
105 (
106 "OFS config-set (MACI) window",
107 0x02C9F000,
108 0x02C9FFFF,
109 "HUM Ch 7 Option-Setting Memory p 278",
110 ),
111 (
112 "anti-rollback counters (ARCCS/ARC_SEC/ARC_NSEC)",
113 0x02F27E00,
114 0x02F27E0F,
115 "HUM Ch 7.2.21-7.2.23 p 296-297",
116 ),
117)
118
119MAX_FINDINGS_SHOWN = 40
120
121
122def _objcopy() -> str:
123 """Return the arm-none-eabi-objcopy path, or fail loudly if it is absent.
124
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).
128 """
129 for cand in (
130 os.environ.get("RA8_OBJCOPY", ""),
131 "arm-none-eabi-objcopy",
132 "objcopy",
133 ):
134 resolved = shutil.which(cand) if cand else None
135 if resolved is not None:
136 return resolved
137 sys.exit(
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."
141 )
142
143
144def parse_ihex(text: str) -> dict[int, int]:
145 """Parse Intel HEX text into an ``{address: byte}`` map.
146
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.
152 """
153 mem: dict[int, int] = {}
154 base = 0
155 for raw_line in text.splitlines():
156 line = raw_line.strip()
157 if not line or not line.startswith(":"):
158 continue
159 try:
160 rec = bytes.fromhex(line[1:])
161 except ValueError:
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):
176 continue
177 return mem
178
179
180def load_image(path: Path) -> dict[int, int]:
181 """Load a ``.hex`` or ``.elf`` firmware image into an ``{address: byte}`` map.
182
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.
186 """
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"):
191 objcopy = _objcopy()
192 with tempfile.TemporaryDirectory() as td:
193 out = Path(td) / "image.hex"
194 proc = subprocess.run( # noqa: S603 # fixed argv, resolved tool
195 [objcopy, "-O", "ihex", str(path), str(out)],
196 capture_output=True,
197 text=True,
198 check=False,
199 )
200 if proc.returncode != 0:
201 sys.exit(
202 f"check_image_no_antirecovery.py: FATAL -- objcopy failed on {path}:\n"
203 f"{proc.stderr}"
204 )
205 return parse_ihex(out.read_text(encoding="latin-1"))
206 # Unknown extension: try Intel HEX, which is the common flashing format.
207 return parse_ihex(path.read_text(encoding="latin-1"))
208
209
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()):
214 if byte == ERASED:
215 continue
216 for name, start, end, cite in ANTI_RECOVERY_WINDOWS:
217 if start <= addr <= end:
218 findings.append(
219 {"addr": addr, "byte": byte, "window": name, "start": start, "cite": cite}
220 )
221 break
222 return findings
223
224
225def _report(image: str, findings: list[dict]) -> None:
226 """Explain which lockdown writes were found and why the flash is refused."""
227 print(
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",
231 file=sys.stderr,
232 )
233 for f in findings[:MAX_FINDINGS_SHOWN]:
234 print(
235 f" 0x{f['addr']:08X} = 0x{f['byte']:02X} [{f['window']}] ({f['cite']})",
236 file=sys.stderr,
237 )
238 if len(findings) > MAX_FINDINGS_SHOWN:
239 print(f" ... {len(findings) - MAX_FINDINGS_SHOWN} more (truncated)", file=sys.stderr)
240 print(
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).",
250 file=sys.stderr,
251 )
252
253
254# --- selftest ----------------------------------------------------------------
255
256
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])
262
263
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])
267
268
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]
272
273
274def selftest() -> int:
275 """Assert the scanner refuses lockdown images and passes benign ones.
276
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.
280 """
281 eof = _ihex_record(0x01, 0, [])
282
283 # BAD: programs PBPS (permanent block protect, 0x0300A800) and ZHUK
284 # (HUK zeroize, 0x0300A900) with non-erased values -> must REFUSE.
285 bad = "\n".join(
286 [
287 _ela(0x0200),
288 _ihex_record(0x00, 0x0000, _word_le(0x20001000)), # a reset vector
289 _ela(0x0300),
290 _ihex_record(0x00, 0xA800, _word_le(0x00000000)), # PBPS lockdown
291 _ihex_record(0x00, 0xA900, _word_le(0x00000000)), # ZHUK lockdown
292 eof,
293 ]
294 )
295 # BAD-2: a lockdown in the extra-MRAM OTP window (0x02E07600).
296 bad2 = "\n".join(
297 [
298 _ela(0x02E0),
299 _ihex_record(0x00, 0x7600, _word_le(0x00000000)),
300 eof,
301 ]
302 )
303 # GOOD: code at 0x02000000, OFS1 programmed to 0xFFFFFFF0 (benign LVD
304 # enable), and the OTP words left erased (0xFFFFFFFF) -- must ALLOW.
305 good = "\n".join(
306 [
307 _ela(0x0200),
308 _ihex_record(0x00, 0x0000, _word_le(0x20001000)),
309 _ihex_record(0x00, 0x0200, [0x00, 0xBF, 0x00, 0xBF]), # nop; nop
310 _ela(0x0300),
311 _ihex_record(0x00, 0xA104, _word_le(0xFFFFFFF0)), # OFS1 (benign)
312 _ihex_record(0x00, 0xA800, _word_le(0xFFFFFFFF)), # PBPS erased
313 _ihex_record(0x00, 0xA900, _word_le(0xFFFFFFFF)), # ZHUK erased
314 eof,
315 ]
316 )
317
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),
322 ]
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)")
329
330 if failures:
331 print("check_image_no_antirecovery.py --selftest: FAILED\n", file=sys.stderr)
332 print("\n".join(failures), file=sys.stderr)
333 return 1
334 refused_n = sum(1 for c in cases if c[2])
335 print(
336 f"check_image_no_antirecovery.py --selftest: PASS "
337 f"({len(cases)} cases: {refused_n} must refuse, {len(cases) - refused_n} must allow)"
338 )
339 return 0
340
341
342def main(argv: list[str]) -> int:
343 """Scan a firmware image for anti-recovery lockdown writes, or run the selftest.
344
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
347 failing selftest.
348 """
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:])
353
354 if args.selftest:
355 return selftest()
356
357 if not args.image:
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)
362 return 1
363
364 mem = load_image(path)
365 findings = scan_memory(mem)
366 if not findings:
367 print(
368 f"check_image_no_antirecovery.py: OK ({path} -- "
369 f"{len(mem)} bytes, no anti-recovery lockdown writes)"
370 )
371 return 0
372
373 _report(str(path), findings)
374 return 1
375
376
377if __name__ == "__main__":
378 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298