4"""Root-of-trust image signer + key ceremony for the RA8D2 secure-boot chain.
6The firmware verifier ``ra8_rot_verify_image`` (libs/ra8_dfu/src/ra8_rot.c) accepts a
7signed image of the form::
9 [ body (body_len bytes) ] [ ra8_rot_trailer_t ]
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.
16The trailer is 116 bytes, little-endian, matching ra8_rot.h::ra8_rot_trailer_t::
18 uint32 magic = 0x524F5431 ("ROT1")
20 uint32 img_version (monotonic anti-rollback counter)
21 uint32 body_len (bytes the digest covers)
23 uint8 digest[32] = SHA-256(body)
24 uint8 sig[64] = ECDSA-P256 raw r||s over the digest
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.
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
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.
48from pathlib
import Path
49from typing
import NoReturn
54SELFTEST_IMAGE_VERSION = 7
59TRAILER_BYTES = 5 * 4 + DIGEST_BYTES + SIG_BYTES
63DER_SEQUENCE_TAG = 0x30
66EC_UNCOMPRESSED_TAG = 0x04
68_OPENSSL = shutil.which(
"openssl")
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")
77def _openssl(args: list[str], stdin: bytes |
None =
None) -> bytes:
78 """Run openssl with ``args``; return stdout bytes; exit on failure."""
80 _fail(
"openssl not found on PATH")
82 proc = subprocess.run(
83 [_OPENSSL, *args], input=stdin, capture_output=
True, check=
False
85 if proc.returncode != 0:
86 sys.stderr.write(proc.stderr.decode(errors=
"replace"))
87 _fail(f
"openssl {args[0]} failed (exit {proc.returncode})")
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)")
96 return off + 2, length
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)")
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")
111def _raw_sig_to_der(r: int, s: int) -> bytes:
112 """Encode two integers as a DER ``SEQUENCE{INTEGER r, INTEGER s}``."""
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:
117 data = b
"\x00" + data
118 return bytes([DER_INTEGER_TAG, len(data)]) + data
120 body = _int_enc(r) + _int_enc(s)
121 return bytes([DER_SEQUENCE_TAG, len(body)]) + body
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)
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"])
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")
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()
152 to_sign = struct.pack(
"<I", img_version) + digest
153 with tempfile.NamedTemporaryFile()
as sign_file:
154 sign_file.write(to_sign)
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
165def _pubkey_c_header(pubkey: bytes) -> str:
166 """Render the 65-byte pubkey as a C initialiser for s_rot_root_pubkey."""
168 " " +
", ".join(f
"0x{b:02X}U" for b
in pubkey[i : i + 8]) +
","
169 for i
in range(0, PUBKEY_BYTES, 8)
171 body =
"\n".join(rows)
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"
180def cmd_keygen(args: argparse.Namespace) -> int:
181 """Run the `keygen` subcommand: mint a root keypair and emit its C header.
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.
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.
194 An existing `--key` path is overwritten without confirmation.
197 args: Parsed namespace with `key` and optional `pubkey_c`.
200 0. Failures surface as exceptions from `keygen`, not a status code.
202 pubkey = keygen(Path(args.key))
203 sys.stdout.write(f
"wrote private key: {args.key}\n")
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")
208 sys.stdout.write(_pubkey_c_header(pubkey))
212def cmd_sign(args: argparse.Namespace) -> int:
213 """Run the `sign` subcommand: append a signed trailer to an image body.
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.
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.
228 args: Parsed namespace with `key`, `image`, `out` and `img_version`.
231 0. Failures surface as exceptions from `sign_body`.
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")
240def cmd_selftest(_args: argparse.Namespace) -> int:
241 """Run the `selftest` subcommand: a throwaway-key sign/verify round trip.
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.
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.
252 Failures exit non-zero from `_require` rather than returning, so the caller
253 never sees a false 0.
256 _args: Unused; the subcommand takes no options.
259 0 when every check passes.
261 with tempfile.TemporaryDirectory()
as tmp:
262 key = Path(tmp) /
"k.pem"
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
267 signed = sign_body(key, body, img_version=SELFTEST_IMAGE_VERSION)
268 _require(len(signed) == len(body) + TRAILER_BYTES,
"signed image length")
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)")
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")
289 with tempfile.TemporaryDirectory()
as 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)
299 str(vdir /
"pub.pem"),
301 str(vdir /
"sig.der"),
302 str(vdir /
"body.bin"),
305 sys.stdout.write(
"rot_sign.py selftest: PASS -- sign/verify round-trip + trailer layout OK.\n")
309def _require(cond: bool, what: str) ->
None:
310 """Exit non-zero unless ``cond`` holds (a checkable assert for the selftest)."""
312 _fail(f
"selftest FAILED: {what}")
316 """Parse the subcommand line and dispatch to the matching `cmd_*` handler.
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.
323 The handler's status, for `sys.exit`.
325 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
326 sub = parser.add_subparsers(dest=
"cmd", required=
True)
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)
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)
340 selftest_p = sub.add_parser(
"selftest", help=
"sign+verify round-trip self-check")
341 selftest_p.set_defaults(func=cmd_selftest)
343 args = parser.parse_args()
344 return int(args.func(args))
347if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.