ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
sign_and_merge.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"""Sign the secure_boot_ns_hil Non-Secure image and merge it with the Secure hex.
5
6The BLXNS root-of-trust proof (#172) needs the NS image SIGNED: the Secure verifier
7reads the NS image's ``.ns_rot_header`` for the body length, locates the appended
8``ra8_rot_trailer_t`` at ns_base + body_len, and checks the ECDSA-P256 signature
9before BLXNS. This build step produces TWO flashable merged hexes from the Secure
10hex + the NS image body:
11
12 * ``<genuine>`` = Secure hex + signed NS image (verify passes -> NS runs)
13 * ``<tampered>`` = Secure hex + signed NS image with (digest mismatch -> denied)
14 one body byte flipped after signing
15
16Signing needs the held-out RoT private key (``scripts/secrets/rot_sign.py``). This step
17DEGRADES GRACEFULLY: if the key is not present it prints the exact command to run
18later (with the key) and exits 0 -- the unsigned NS body is left for reference and
19the build still succeeds. Run this script by hand (``--key <path>``) once the key
20is available, or set ``RA8_ROT_KEY`` before the build.
21
22It shells out to ``objcopy`` (bin<->ihex), ``scripts/secrets/rot_sign.py`` (sign), and
23``scripts/gen/merge_ihex.py`` (merge); no third-party Python packages.
24"""
25
26import argparse
27import subprocess
28import sys
29from pathlib import Path
30
31# The NS image is flashed at the Secure MRAM LMA (matches ns_image.ld NS_LOAD).
32NS_LOAD_ADDR = 0x02080000
33# Byte flipped to build the tampered case: the first .text byte, immediately
34# after the 64-byte vector table + 8-byte .ns_rot_header. Definitely inside the
35# signed body and NOT the header/vectors, so the header still parses but the body
36# digest no longer matches -> the RoT gate default-denies.
37TAMPER_OFFSET = 0x48
38TAMPER_XOR = 0x01
39IMG_VERSION = 1
40
41# The .ns_rot_header the NS linker embeds (matches ra8_tz_secure_boot.h /
42# ns_image.ld): "NSR1" magic + the signed body_len, at this byte offset.
43NS_ROT_HEADER_OFFSET = 0x40
44NS_ROT_HEADER_MAGIC = 0x3152534E # "NSR1" little-endian
45
46
47def _read_header_body_len(body: bytes) -> int:
48 """Return the body_len the NS linker recorded in the .ns_rot_header.
49
50 The header (magic + body_len) sits at NS_ROT_HEADER_OFFSET. This is the
51 authoritative signed-body length the firmware verifier reads; the raw objcopy
52 output can be SHORTER when a trailing empty section (e.g. an empty .data)
53 contributes only alignment, so the caller pads up to this length before
54 signing -- guaranteeing the trailer lands at ns_base + body_len.
55 """
56 off = NS_ROT_HEADER_OFFSET
57 if len(body) < off + 8:
58 _die = f"NS image too small ({len(body)} B) to hold the .ns_rot_header"
59 raise SystemExit(_die)
60 magic = int.from_bytes(body[off : off + 4], "little")
61 if magic != NS_ROT_HEADER_MAGIC:
62 _die = f"NS .ns_rot_header magic {magic:#010x} != {NS_ROT_HEADER_MAGIC:#010x} (NSR1)"
63 raise SystemExit(_die)
64 return int.from_bytes(body[off + 4 : off + 8], "little")
65
66
67def _run(cmd: list[str]) -> None:
68 """Run a subprocess, echoing the command; raise on failure."""
69 sys.stdout.write(" $ " + " ".join(str(c) for c in cmd) + "\n")
70 subprocess.run(cmd, check=True) # noqa: S603 -- args are this build's own literals
71
72
73def _bin_to_hex(objcopy: str, bin_path: Path, hex_path: Path) -> None:
74 """Convert a raw binary at NS_LOAD_ADDR into an Intel HEX file."""
75 _run(
76 [
77 objcopy,
78 "-I",
79 "binary",
80 "-O",
81 "ihex",
82 f"--change-addresses={NS_LOAD_ADDR:#x}",
83 str(bin_path),
84 str(hex_path),
85 ]
86 )
87
88
89def _sign(rot_sign: str, key: Path, body: Path, out: Path) -> None:
90 """Append a signed ra8_rot_trailer_t to ``body`` -> ``out``."""
91 _run(
92 [
93 sys.executable,
94 rot_sign,
95 "sign",
96 "--key",
97 str(key),
98 "--image",
99 str(body),
100 "--out",
101 str(out),
102 "--img-version",
103 str(IMG_VERSION),
104 ]
105 )
106
107
108def _merge(merge_tool: str, secure_hex: Path, ns_hex: Path, out_hex: Path) -> None:
109 """Merge the Secure hex and an NS hex into one flashable hex."""
110 _run([sys.executable, merge_tool, str(secure_hex), str(ns_hex), str(out_hex)])
111
112
113def _tampered_copy(signed: Path, out: Path) -> None:
114 """Copy ``signed`` and flip one body byte so the RoT digest check fails."""
115 data = bytearray(signed.read_bytes())
116 data[TAMPER_OFFSET] ^= TAMPER_XOR
117 out.write_bytes(data)
118
119
120def _emit_manual_recipe(args: argparse.Namespace, ns_bin: Path) -> None:
121 """Print the exact command to sign+merge later, when the key is available."""
122 sys.stdout.write(
123 "sign_and_merge: RoT signing key not found -- NS image left UNSIGNED.\n"
124 f" (looked for: {args.key or '<none given>'})\n"
125 f" Unsigned NS body: {ns_bin}\n"
126 " The genuine/tampered merged hexes were NOT produced. On the bench,\n"
127 " with the key present, run:\n\n"
128 f" python3 {Path(__file__).resolve()} \\\n"
129 f" --secure-hex {args.secure_hex} \\\n"
130 f" --ns-elf {args.ns_elf} \\\n"
131 f" --objcopy {args.objcopy} \\\n"
132 f" --rot-sign {args.rot_sign} \\\n"
133 f" --merge {args.merge} \\\n"
134 f" --out-genuine {args.out_genuine} \\\n"
135 f" --out-tampered {args.out_tampered} \\\n"
136 " --key $HOME/ra8d2-rot-signing-key.pem\n\n"
137 " Then flash --out-genuine (NS runs) and --out-tampered (NS denied).\n"
138 )
139
140
141def main() -> int:
142 """Sign the NS image and emit the genuine and tampered merged hexes.
143
144 Pipeline: extract the raw NS body from its ELF with objcopy, pad it to the
145 `body_len` the linker recorded in `.ns_rot_header`, sign the padded body,
146 convert to ihex at the LMA, and merge with the Secure hex. The tampered
147 output is the same signed image with one body byte flipped AFTER signing, so
148 it differs from the genuine one only in that the digest no longer matches.
149
150 The padding step is not incidental. objcopy drops a trailing empty section's
151 alignment tail, so the raw output can be a few bytes short of the linker's
152 loaded-image extent. Signing the short body would put the trailer at the
153 wrong address and hash different bytes than the firmware does -- verification
154 would fail on a correctly-signed image. Padding to `body_len` makes the
155 signed body exactly what the firmware hashes.
156
157 Degrades deliberately when the RoT key is absent: it prints the exact
158 command to run later with the key and returns 0, leaving the unsigned NS
159 body for reference so the build still succeeds. **A 0 return therefore does
160 not mean the images were produced.** Check for the output files, not the
161 exit status.
162
163 Returns:
164 0 on success, and also 0 on the no-key degrade path.
165
166 Raises:
167 SystemExit: The NS body is larger than the header's `body_len`, or a
168 subprocess (objcopy, rot_sign, merge_ihex) failed.
169 """
170 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
171 parser.add_argument("--secure-hex", required=True, help="Secure ELF's ihex")
172 parser.add_argument("--ns-elf", required=True, help="Non-Secure ELF")
173 parser.add_argument("--objcopy", required=True, help="arm-none-eabi-objcopy path")
174 parser.add_argument("--rot-sign", required=True, help="scripts/secrets/rot_sign.py path")
175 parser.add_argument("--merge", required=True, help="scripts/gen/merge_ihex.py path")
176 parser.add_argument("--out-genuine", required=True, help="output genuine merged hex")
177 parser.add_argument("--out-tampered", required=True, help="output tampered merged hex")
178 parser.add_argument("--key", default="", help="RoT private-key PEM (empty -> degrade)")
179 args = parser.parse_args()
180
181 out_dir = Path(args.out_genuine).parent
182 ns_bin = out_dir / "secure_boot_ns_hil_ns.bin"
183
184 # 1. Extract the raw NS body from its ELF (this is what rot_sign.py signs).
185 _run([args.objcopy, "-O", "binary", str(args.ns_elf), str(ns_bin)])
186
187 # Pad the raw body up to the body_len the linker recorded in .ns_rot_header.
188 # objcopy drops a trailing empty section's alignment tail, so the raw output
189 # can be a few bytes short of the linker's loaded-image extent; padding to
190 # body_len makes the signed body match exactly what the firmware hashes and
191 # puts the trailer at ns_base + body_len.
192 raw = ns_bin.read_bytes()
193 body_len = _read_header_body_len(raw)
194 if len(raw) > body_len:
195 _die = f"NS body {len(raw)} B exceeds header body_len {body_len} B"
196 raise SystemExit(_die)
197 body = out_dir / "secure_boot_ns_hil_ns_body.bin"
198 body.write_bytes(raw + b"\x00" * (body_len - len(raw)))
199
200 key = Path(args.key) if args.key else None
201 if key is None or not key.is_file():
202 _emit_manual_recipe(args, ns_bin)
203 return 0
204
205 # 2. Genuine: sign the (padded) NS body, convert to ihex at the LMA, merge.
206 signed = out_dir / "secure_boot_ns_hil_ns_signed.bin"
207 signed_hex = out_dir / "secure_boot_ns_hil_ns_signed.hex"
208 _sign(args.rot_sign, key, body, signed)
209 _bin_to_hex(args.objcopy, signed, signed_hex)
210 _merge(args.merge, Path(args.secure_hex), signed_hex, Path(args.out_genuine))
211
212 # 3. Tampered: flip one body byte after signing, convert, merge.
213 tampered = out_dir / "secure_boot_ns_hil_ns_tampered.bin"
214 tampered_hex = out_dir / "secure_boot_ns_hil_ns_tampered.hex"
215 _tampered_copy(signed, tampered)
216 _bin_to_hex(args.objcopy, tampered, tampered_hex)
217 _merge(args.merge, Path(args.secure_hex), tampered_hex, Path(args.out_tampered))
218
219 sys.stdout.write(
220 "sign_and_merge: OK\n"
221 f" genuine -> {args.out_genuine} (flash: NS liveness advances)\n"
222 f" tampered -> {args.out_tampered} "
223 f"(body byte {TAMPER_OFFSET:#x} flipped: RoT denies, NS never runs)\n"
224 )
225 return 0
226
227
228if __name__ == "__main__":
229 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298