ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
rot_sign.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"""Root-of-trust image signer + key ceremony for the RA8D2 secure-boot chain.
5
6The firmware verifier ``ra8_rot_verify_image`` (libs/ra8_dfu/src/ra8_rot.c) accepts a
7signed image of the form::
8
9 [ body (body_len bytes) ] [ ra8_rot_trailer_t ]
10
11and default-denies anything without a valid trailer. Nothing in-tree could
12*produce* that trailer, so ``RA8_ENABLE_ROOT_OF_TRUST`` could never be turned on.
13This tool closes that gap. It is a build/release-time host tool (never a CI
14gate); it shells out to ``openssl`` so it needs no third-party Python packages.
15
16The trailer is 116 bytes, little-endian, matching ra8_rot.h::ra8_rot_trailer_t::
17
18 uint32 magic = 0x524F5431 ("ROT1")
19 uint32 version = 1
20 uint32 img_version (monotonic anti-rollback counter)
21 uint32 body_len (bytes the digest covers)
22 uint32 sig_len = 64
23 uint8 digest[32] = SHA-256(body)
24 uint8 sig[64] = ECDSA-P256 raw r||s over the digest
25
26The public key is the 65-byte uncompressed P-256 point ``0x04 || X || Y`` that is
27embedded in ra8_rot.c::s_rot_root_pubkey.
28
29Commands::
30
31 rot_sign.py keygen --key priv.pem [--pubkey-c pubkey.h]
32 rot_sign.py sign --key priv.pem --image body.bin --out signed.bin
33 [--img-version N]
34 rot_sign.py selftest
35
36``selftest`` generates a throwaway key, signs a random body, then re-verifies the
37signature with openssl and checks the trailer layout -- proving the produced
38signature is valid ECDSA-P256 over SHA-256(body) with a 116-byte trailer.
39"""
40
41import argparse
42import hashlib
43import shutil
44import struct
45import subprocess
46import sys
47import tempfile
48from pathlib import Path
49from typing import NoReturn
50
51# Mirrors libs/ra8_dfu/inc/ra8_rot.h.
52ROT_MAGIC = 0x524F5431 # "ROT1"
53ROT_VERSION = 1
54SELFTEST_IMAGE_VERSION = 7
55DIGEST_BYTES = 32
56SIG_BYTES = 64
57PUBKEY_BYTES = 65
58BODY_MAX = 0x00100000 # 1 MiB signable-body cap (k_ra8_rot_body_max)
59TRAILER_BYTES = 5 * 4 + DIGEST_BYTES + SIG_BYTES # 116
60INT_BYTES = 32 # P-256 field element / half-signature width
61
62# ASN.1 DER / SEC1 tag bytes used when (de)serialising the openssl signature.
63DER_SEQUENCE_TAG = 0x30
64DER_INTEGER_TAG = 0x02
65DER_HIGH_BIT = 0x80
66EC_UNCOMPRESSED_TAG = 0x04
67
68_OPENSSL = shutil.which("openssl")
69
70
71def _fail(message: str) -> NoReturn:
72 """Print ``message`` to stderr and exit non-zero (checkable, no traceback)."""
73 sys.stderr.write(f"rot_sign.py: {message}\n")
74 raise SystemExit(1)
75
76
77def _openssl(args: list[str], stdin: bytes | None = None) -> bytes:
78 """Run openssl with ``args``; return stdout bytes; exit on failure."""
79 if _OPENSSL is None:
80 _fail("openssl not found on PATH")
81 # The arg list is built from this tool's own literals, not untrusted input.
82 proc = subprocess.run( # noqa: S603 -- resolved OpenSSL and fixed option vocabulary
83 [_OPENSSL, *args], input=stdin, capture_output=True, check=False
84 )
85 if proc.returncode != 0:
86 sys.stderr.write(proc.stderr.decode(errors="replace"))
87 _fail(f"openssl {args[0]} failed (exit {proc.returncode})")
88 return proc.stdout
89
90
91def _der_int(buf: bytes, off: int) -> tuple[int, int]:
92 """Parse one DER INTEGER at ``buf[off]``; return (value_offset, value_len)."""
93 if buf[off] != DER_INTEGER_TAG:
94 _fail("malformed DER signature (expected INTEGER)")
95 length = buf[off + 1]
96 return off + 2, length
97
98
99def _der_sig_to_raw(der: bytes) -> bytes:
100 """Convert a DER ``SEQUENCE{INTEGER r, INTEGER s}`` to 64-byte r||s."""
101 if der[0] != DER_SEQUENCE_TAG:
102 _fail("malformed DER signature (expected SEQUENCE)")
103 # Skip the SEQUENCE tag + single-byte length (an ECDSA-P256 sig is < 128 B).
104 r_off, r_len = _der_int(der, 2)
105 s_off, s_len = _der_int(der, r_off + r_len)
106 r = int.from_bytes(der[r_off : r_off + r_len], "big")
107 s = int.from_bytes(der[s_off : s_off + s_len], "big")
108 return r.to_bytes(INT_BYTES, "big") + s.to_bytes(INT_BYTES, "big")
109
110
111def _raw_sig_to_der(r: int, s: int) -> bytes:
112 """Encode two integers as a DER ``SEQUENCE{INTEGER r, INTEGER s}``."""
113
114 def _int_enc(value: int) -> bytes:
115 data = value.to_bytes(INT_BYTES, "big").lstrip(b"\x00") or b"\x00"
116 if data[0] & DER_HIGH_BIT: # keep the ASN.1 integer positive
117 data = b"\x00" + data
118 return bytes([DER_INTEGER_TAG, len(data)]) + data
119
120 body = _int_enc(r) + _int_enc(s)
121 return bytes([DER_SEQUENCE_TAG, len(body)]) + body
122
123
124def keygen(key_path: Path) -> bytes:
125 """Generate a P-256 private key at ``key_path``; return the 65-byte pubkey."""
126 pem = _openssl(["ecparam", "-name", "prime256v1", "-genkey", "-noout"])
127 key_path.write_bytes(pem)
128 return public_key(key_path)
129
130
131def public_key(key_path: Path) -> bytes:
132 """Return the 65-byte uncompressed 0x04||X||Y public key for ``key_path``."""
133 der = _openssl(["ec", "-in", str(key_path), "-pubout", "-outform", "DER"])
134 # The SubjectPublicKeyInfo for P-256 ends in the 65-byte uncompressed point.
135 point = der[-PUBKEY_BYTES:]
136 if len(point) != PUBKEY_BYTES or point[0] != EC_UNCOMPRESSED_TAG:
137 _fail("could not extract uncompressed P-256 public key")
138 return point
139
140
141def sign_body(key_path: Path, body: bytes, img_version: int) -> bytes:
142 """Return ``body`` with an appended ra8_rot_trailer_t signed by ``key_path``."""
143 if len(body) == 0 or len(body) > BODY_MAX:
144 _fail(f"body length {len(body)} out of range (1..{BODY_MAX})")
145 digest = hashlib.sha256(body).digest()
146 # The signature authenticates SHA-256(img_version_le || body_digest), not the
147 # bare body digest, so a forged img_version cannot ride on a validly-signed
148 # body (T5-05). `openssl dgst -sha256 -sign` hashes its input, so signing the
149 # concatenation yields ECDSA(SHA-256(img_version_le || digest)) -- exactly
150 # what ra8_rot_verify_image recomputes via internal_bind_version. The trailer's
151 # own `digest` field still stores SHA-256(body) for the tamper pre-check.
152 to_sign = struct.pack("<I", img_version) + digest
153 with tempfile.NamedTemporaryFile() as sign_file:
154 sign_file.write(to_sign)
155 sign_file.flush()
156 der = _openssl(["dgst", "-sha256", "-sign", str(key_path), sign_file.name])
157 sig = _der_sig_to_raw(der)
158 header = struct.pack("<5I", ROT_MAGIC, ROT_VERSION, img_version, len(body), SIG_BYTES)
159 trailer = header + digest + sig
160 if len(trailer) != TRAILER_BYTES:
161 _fail(f"trailer size {len(trailer)} != {TRAILER_BYTES}")
162 return body + trailer
163
164
165def _pubkey_c_header(pubkey: bytes) -> str:
166 """Render the 65-byte pubkey as a C initialiser for s_rot_root_pubkey."""
167 rows = [
168 " " + ", ".join(f"0x{b:02X}U" for b in pubkey[i : i + 8]) + ","
169 for i in range(0, PUBKEY_BYTES, 8)
170 ]
171 body = "\n".join(rows)
172 return (
173 "/* Provisioned root public key -- paste into "
174 "libs/ra8_dfu/src/ra8_rot.c::s_rot_root_pubkey. */\n"
175 "static const uint8_t s_rot_root_pubkey[k_ra8_rot_pubkey_bytes] = {\n"
176 f"{body}\n}};\n"
177 )
178
179
180def cmd_keygen(args: argparse.Namespace) -> int:
181 """Run the `keygen` subcommand: mint a root keypair and emit its C header.
182
183 This is the key ceremony. The private key written to `--key` is the root of
184 the entire secure-boot chain: losing it means no future image can ever be
185 signed for a board already provisioned with the matching public key, and
186 leaking it means anyone can. It is written wherever the caller says, with no
187 passphrase and no permission hardening -- protecting it is the operator's
188 job, not this tool's.
189
190 The public half is emitted as a C array for
191 `libs/ra8_dfu/src/ra8_rot.c::s_rot_root_pubkey`. Without `--pubkey-c` it
192 goes to stdout, so the ceremony can be eyeballed before anything is pasted.
193
194 An existing `--key` path is overwritten without confirmation.
195
196 Args:
197 args: Parsed namespace with `key` and optional `pubkey_c`.
198
199 Returns:
200 0. Failures surface as exceptions from `keygen`, not a status code.
201 """
202 pubkey = keygen(Path(args.key))
203 sys.stdout.write(f"wrote private key: {args.key}\n")
204 if args.pubkey_c:
205 Path(args.pubkey_c).write_text(_pubkey_c_header(pubkey), encoding="ascii")
206 sys.stdout.write(f"wrote public-key C header: {args.pubkey_c}\n")
207 else:
208 sys.stdout.write(_pubkey_c_header(pubkey))
209 return 0
210
211
212def cmd_sign(args: argparse.Namespace) -> int:
213 """Run the `sign` subcommand: append a signed trailer to an image body.
214
215 Output is the input body followed by the 116-byte `ra8_rot_trailer_t`, so
216 `--out` is always exactly 116 bytes longer than `--image`. Signing is not
217 idempotent: feeding an already-signed image back in signs the body AND the
218 old trailer, producing a double-trailered image the verifier rejects. Always
219 sign the raw build artifact.
220
221 `--img-version` is the monotonic anti-rollback counter, and the device
222 refuses any image whose value is below what it has already accepted.
223 Signing with a number lower than one already deployed produces a
224 correctly-signed image that the target will still refuse -- and re-using a
225 number is what silently permits a rollback.
226
227 Args:
228 args: Parsed namespace with `key`, `image`, `out` and `img_version`.
229
230 Returns:
231 0. Failures surface as exceptions from `sign_body`.
232 """
233 body = Path(args.image).read_bytes()
234 signed = sign_body(Path(args.key), body, args.img_version)
235 Path(args.out).write_bytes(signed)
236 sys.stdout.write(f"signed {len(body)} body bytes -> {args.out} ({len(signed)} bytes)\n")
237 return 0
238
239
240def cmd_selftest(_args: argparse.Namespace) -> int:
241 """Run the `selftest` subcommand: a throwaway-key sign/verify round trip.
242
243 Proves this tool emits exactly the byte layout `ra8_rot_verify_image()`
244 parses -- magic, version, field widths and offsets, digest placement, and
245 that `img_version` survives the round trip. It checks the FORMAT contract,
246 not the crypto: openssl owns ECDSA correctness.
247
248 The keypair is generated into a temporary directory and destroyed with it,
249 so this touches no provisioned key and is safe to run anywhere. It needs
250 `openssl` on PATH and no hardware.
251
252 Failures exit non-zero from `_require` rather than returning, so the caller
253 never sees a false 0.
254
255 Args:
256 _args: Unused; the subcommand takes no options.
257
258 Returns:
259 0 when every check passes.
260 """
261 with tempfile.TemporaryDirectory() as tmp:
262 key = Path(tmp) / "k.pem"
263 pubkey = keygen(key)
264 _require(pubkey[0] == EC_UNCOMPRESSED_TAG, "pubkey point-format")
265 _require(len(pubkey) == PUBKEY_BYTES, "pubkey length")
266 body = hashlib.sha256(b"rot-selftest").digest() * 4 # 128 arbitrary bytes
267 signed = sign_body(key, body, img_version=SELFTEST_IMAGE_VERSION)
268 _require(len(signed) == len(body) + TRAILER_BYTES, "signed image length")
269
270 magic, ver, imgv, blen, slen = struct.unpack("<5I", signed[len(body) : len(body) + 20])
271 _require(magic == ROT_MAGIC, "trailer magic")
272 _require(ver == ROT_VERSION, "trailer version")
273 _require(imgv == SELFTEST_IMAGE_VERSION, "img_version round-trip")
274 _require(blen == len(body), "body_len")
275 _require(slen == SIG_BYTES, "sig_len")
276 digest = signed[len(body) + 20 : len(body) + 20 + DIGEST_BYTES]
277 _require(digest == hashlib.sha256(body).digest(), "trailer digest == SHA-256(body)")
278
279 # Re-verify the raw r||s signature against the public key via openssl:
280 # rebuild the DER form and check ECDSA-P256 over the version-bound
281 # material SHA-256(img_version_le || digest) -- the same bytes
282 # ra8_rot_verify_image reconstructs. _openssl exits on a failed verify, so
283 # reaching the print means the signature is valid over that material.
284 signed_material_input = struct.pack("<I", imgv) + digest
285 raw = signed[len(body) + 20 + DIGEST_BYTES :]
286 der = _raw_sig_to_der(
287 int.from_bytes(raw[:INT_BYTES], "big"), int.from_bytes(raw[INT_BYTES:], "big")
288 )
289 with tempfile.TemporaryDirectory() as vtmp:
290 vdir = Path(vtmp)
291 (vdir / "pub.pem").write_bytes(_openssl(["ec", "-in", str(key), "-pubout"]))
292 (vdir / "body.bin").write_bytes(signed_material_input)
293 (vdir / "sig.der").write_bytes(der)
294 _openssl(
295 [
296 "dgst",
297 "-sha256",
298 "-verify",
299 str(vdir / "pub.pem"),
300 "-signature",
301 str(vdir / "sig.der"),
302 str(vdir / "body.bin"),
303 ]
304 )
305 sys.stdout.write("rot_sign.py selftest: PASS -- sign/verify round-trip + trailer layout OK.\n")
306 return 0
307
308
309def _require(cond: bool, what: str) -> None:
310 """Exit non-zero unless ``cond`` holds (a checkable assert for the selftest)."""
311 if not cond:
312 _fail(f"selftest FAILED: {what}")
313
314
315def main() -> int:
316 """Parse the subcommand line and dispatch to the matching `cmd_*` handler.
317
318 Three subcommands -- `keygen`, `sign`, `selftest` -- and one is required, so
319 a bare invocation is an argparse error rather than a default action. That
320 is deliberate for a tool whose default could otherwise overwrite a root key.
321
322 Returns:
323 The handler's status, for `sys.exit`.
324 """
325 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
326 sub = parser.add_subparsers(dest="cmd", required=True)
327
328 keygen_p = sub.add_parser("keygen", help="generate a P-256 root keypair")
329 keygen_p.add_argument("--key", required=True, help="output private-key PEM path")
330 keygen_p.add_argument("--pubkey-c", help="output C header with s_rot_root_pubkey")
331 keygen_p.set_defaults(func=cmd_keygen)
332
333 sign_p = sub.add_parser("sign", help="append a signed ra8_rot_trailer_t to an image")
334 sign_p.add_argument("--key", required=True, help="private-key PEM from keygen")
335 sign_p.add_argument("--image", required=True, help="raw image body .bin")
336 sign_p.add_argument("--out", required=True, help="output signed image path")
337 sign_p.add_argument("--img-version", type=int, default=1, help="anti-rollback version")
338 sign_p.set_defaults(func=cmd_sign)
339
340 selftest_p = sub.add_parser("selftest", help="sign+verify round-trip self-check")
341 selftest_p.set_defaults(func=cmd_selftest)
342
343 args = parser.parse_args()
344 return int(args.func(args))
345
346
347if __name__ == "__main__":
348 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298