ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
rot_keystore.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"""Versioned, tagged store for root-of-trust signing keys.
5
6Keeps a HISTORY of every RoT signing key so you can create new credentials
7whenever you want and still recover any prior key -- with or without the team
8OpenBao vault. Two backends, auto-selected (override with --backend):
9
10 openbao KV v2 at BAO_KV_MOUNT / BAO_ROT_SECRET_PATH (default
11 secret / ra8d2/rot-signing-key). Native versioning = the history.
12 Reachability + identity come from openbao_client.py.
13 local a 0700 directory (RA8_ROT_STORE_DIR, default ~/.config/ra8/rot)
14 holding one PEM per version plus history.json. This is the
15 "no vault" path: cloning the repo and doing RoT work needs nothing
16 but this script and openssl.
17
18The two backend classes are interchangeable by construction -- same ``store``
19/ ``get`` / ``history`` / ``name`` surface -- so every command below works
20identically whichever one ``select_backend`` returns, and losing vault access
21degrades the tool rather than breaking it.
22
23Every stored version is tagged with: fingerprint (SHA-256 of the public SPKI,
24matching rot_provision.sh), algorithm, created_at (UTC), git_commit, note.
25
26Commands:
27 store [--key PEM] [--note TEXT] [--backend B] persist a key as a new version
28 get [--version N] [--out PEM] [--backend B] recover a version's private key
29 history [--backend B] list every version + its tags
30 status [--backend B] active backend + latest version
31 rekey [--patch] [--note TEXT] [--out PEM] [--backend B]
32 generate a NEW key, back up the current one, store + tag the new
33 version, optionally provision it into ra8_rot.c, install as working key
34"""
35
36from __future__ import annotations
37
38import argparse
39import json
40import os
41import subprocess
42import sys
43import tempfile
44from datetime import UTC, datetime
45from pathlib import Path
46
47sys.path.insert(0, str(Path(__file__).resolve().parent))
48from openbao_client import OpenBaoClient, OpenBaoError
49
50_REPO_ROOT = Path(__file__).resolve().parents[2]
51_ROT_C = _REPO_ROOT / "libs" / "ra8_dfu" / "src" / "ra8_rot.c"
52_ROT_SIGN = _REPO_ROOT / "tools" / "rot" / "src" / "rot_sign.py"
53_ROT_PATCH = _REPO_ROOT / "tools" / "rot" / "src" / "rot_patch_pubkey.py"
54_WORKING_KEY = Path.home() / "ra8d2-rot-signing-key.pem"
55_ALGORITHM = "ecdsa-p256"
56
57
58def _now_iso() -> str:
59 return datetime.now(UTC).isoformat(timespec="seconds")
60
61
62def _git_commit() -> str:
63 try:
64 out = subprocess.run( # noqa: S603 -- fixed Git argv, no shell
65 ["git", "-C", str(_REPO_ROOT), "rev-parse", "--short", "HEAD"], # noqa: S607 -- Git is an operator prerequisite
66 capture_output=True,
67 text=True,
68 check=True,
69 )
70 except (subprocess.CalledProcessError, FileNotFoundError):
71 return "unknown"
72 return out.stdout.strip() or "unknown"
73
74
75def fingerprint(pem_path: Path) -> str:
76 """SHA-256 of the public SPKI, matching rot_provision.sh's fingerprint."""
77 spki = subprocess.run( # noqa: S603 -- explicit argv is never a shell
78 ["openssl", "ec", "-in", str(pem_path), "-pubout"], # noqa: S607 -- OpenSSL is an operator prerequisite
79 capture_output=True,
80 check=True,
81 ).stdout
82 dgst = subprocess.run(
83 ["openssl", "dgst", "-sha256"], # noqa: S607 -- OpenSSL is an operator prerequisite
84 input=spki,
85 capture_output=True,
86 check=True,
87 ).stdout.decode()
88 return dgst.split()[-1]
89
90
91def _tags(pem_path: Path, note: str) -> dict[str, str]:
92 return {
93 "fingerprint": fingerprint(pem_path),
94 "algorithm": _ALGORITHM,
95 "created_at": _now_iso(),
96 "git_commit": _git_commit(),
97 "note": note,
98 }
99
100
101class LocalBackend:
102 """File-backed versioned store for operators without the OpenBao vault."""
103
104 def __init__(self) -> None:
105 """Resolve the store directory and create it 0700 if it does not exist.
106
107 Constructing the backend is what creates the directory, so every other
108 method may assume it is present. The 0700 mode is applied at creation
109 rather than checked afterwards: these are private signing keys, and a
110 store that was briefly world-readable has already leaked.
111 """
112 override = os.environ.get("RA8_ROT_STORE_DIR", "").strip()
113 self.dir = Path(override) if override else (Path.home() / ".config" / "ra8" / "rot")
114 self.dir.mkdir(mode=0o700, parents=True, exist_ok=True)
115 self.index = self.dir / "history.json"
116
117 @property
118 def name(self) -> str:
119 """Human-readable backend identity, including the resolved directory.
120
121 Printed by every command so the operator can see WHICH store was
122 written -- the directory is environment-overridable, and a rekey into
123 an unexpected store is the mistake worth making visible.
124 """
125 return f"local ({self.dir})"
126
127 def _load(self) -> list[dict]:
128 if not self.index.exists():
129 return []
130 return json.loads(self.index.read_text(encoding="utf-8"))
131
132 def _save(self, entries: list[dict]) -> None:
133 self.index.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8")
134 self.index.chmod(0o600)
135
136 def store(self, pem: str, tags: dict[str, str]) -> int:
137 """Persist ``pem`` as a new version and return that version number.
138
139 Idempotent against the newest entry: re-storing a key whose fingerprint
140 already matches the latest version returns that version instead of
141 appending a duplicate. ``rekey`` relies on this -- it backs up the
142 outgoing working key on every run, and without the check a repeated
143 rekey would inflate the history with copies of the same key.
144
145 Note it compares only the LAST entry, so re-storing a key that was
146 current several versions ago does create a new version. That is
147 intended: returning to an older key is a real event worth recording.
148
149 Both the PEM and the index are written 0600.
150 """
151 entries = self._load()
152 if entries and entries[-1]["fingerprint"] == tags["fingerprint"]:
153 return int(entries[-1]["version"])
154 version = len(entries) + 1
155 fname = f"rot-v{version:03d}-{tags['fingerprint'][:12]}.pem"
156 key_file = self.dir / fname
157 key_file.write_text(pem, encoding="ascii")
158 key_file.chmod(0o600)
159 entries.append({"version": version, "file": fname, **tags})
160 self._save(entries)
161 return version
162
163 def get(self, version: int | None) -> str:
164 """Return the private-key PEM for ``version``, or the newest when None.
165
166 Raises OpenBaoError -- not a local-specific exception -- for both an
167 empty store and an unknown version, so callers can handle failures from
168 either backend with one except clause.
169 """
170 entries = self._load()
171 if not entries:
172 msg = "local store is empty"
173 raise OpenBaoError(msg)
174 entry = (
175 entries[-1]
176 if version is None
177 else next((e for e in entries if int(e["version"]) == version), None)
178 )
179 if entry is None:
180 msg = f"version {version} not in local store"
181 raise OpenBaoError(msg)
182 return (self.dir / entry["file"]).read_text(encoding="ascii")
183
184 def history(self) -> list[dict]:
185 """Every stored version, oldest first, as tag dicts.
186
187 Ordering is the contract the callers depend on: ``status`` and
188 ``store`` both read ``[-1]`` as "the current key".
189 """
190 return self._load()
191
192
193class OpenBaoBackend:
194 """KV v2-backed versioned store; the vault's own versioning is the history."""
195
196 def __init__(self, client: OpenBaoClient) -> None:
197 """Bind this backend to an already-constructed, configured vault client.
198
199 The client is injected rather than built here so ``select_backend`` can
200 test reachability (and fall back to local) before committing to the
201 vault path -- this constructor performs no I/O and cannot fail.
202 """
203 self.client = client
204 self.path = os.environ.get("BAO_ROT_SECRET_PATH", "ra8d2/rot-signing-key")
205
206 @property
207 def name(self) -> str:
208 """Human-readable backend identity: vault address, mount and secret path.
209
210 Includes the address so an operator can see which vault was written
211 when several environments are configured.
212 """
213 return f"openbao ({self.client.addr}, {self.client.mount}/{self.path})"
214
215 def store(self, pem: str, tags: dict[str, str]) -> int:
216 """Write ``pem`` as a new KV v2 version and return that version number.
217
218 Fingerprint-idempotent against the newest version, exactly as the local
219 backend is, so ``rekey`` does not accumulate duplicate versions of the
220 same key whichever store is active.
221
222 Metadata is rewritten on every store. That is deliberate: it costs one
223 request and keeps the purpose/algorithm annotation correct even if the
224 secret was first created by hand or by an older version of this tool.
225 """
226 latest = self.history()
227 if latest and latest[-1]["fingerprint"] == tags["fingerprint"]:
228 return int(latest[-1]["version"])
229 version = self.client.kv_put(self.path, {"pem": pem, **tags})
230 self.client.kv_put_metadata(
231 self.path,
232 {"purpose": "RA8 root-of-trust signing key", "algorithm": _ALGORITHM},
233 )
234 return version
235
236 def get(self, version: int | None) -> str:
237 """Return the private-key PEM for ``version``, or the newest when None.
238
239 A version that exists but carries no ``pem`` field raises rather than
240 returning empty: that shape means the secret was written by something
241 other than this tool, and handing back "" would look like a key.
242 """
243 data = self.client.kv_get(self.path, version=version)
244 if "pem" not in data:
245 msg = f"no RoT key at version {version or 'latest'}"
246 raise OpenBaoError(msg)
247 return data["pem"]
248
249 def history(self) -> list[dict]:
250 """Every live vault version, oldest first, flattened to local-backend shape.
251
252 Deleted and destroyed versions are dropped, so the list holds only
253 versions whose PEM can still be recovered -- ``[-1]`` therefore means
254 "current key" for both backends, and ``status`` needs no special case.
255
256 Costs one request per surviving version, because KV v2 metadata carries
257 no user fields; the tag values live in each version's data. Missing tags
258 degrade to "?" rather than raising, so a hand-written secret still
259 lists.
260 """
261 meta = self.client.kv_metadata(self.path)
262 versions = meta.get("versions", {})
263 out: list[dict] = []
264 for vnum in sorted(versions, key=int):
265 if versions[vnum].get("destroyed") or versions[vnum].get("deletion_time"):
266 continue
267 data = self.client.kv_get(self.path, version=int(vnum))
268 out.append(
269 {
270 "version": int(vnum),
271 "fingerprint": data.get("fingerprint", "?"),
272 "algorithm": data.get("algorithm", "?"),
273 "created_at": data.get("created_at", versions[vnum].get("created_time", "?")),
274 "git_commit": data.get("git_commit", "?"),
275 "note": data.get("note", ""),
276 }
277 )
278 return out
279
280
281def select_backend(prefer: str) -> LocalBackend | OpenBaoBackend:
282 """Pick a backend: 'local', 'openbao', or 'auto' (vault if reachable)."""
283 if prefer == "local":
284 return LocalBackend()
285 client = OpenBaoClient()
286 if prefer == "openbao":
287 if not client.configured:
288 sys.exit("openbao backend requested but no BAO_ADDR / AppRole creds found")
289 return OpenBaoBackend(client)
290 # auto: prefer the vault when it is configured AND reachable, else local.
291 if client.configured:
292 try:
293 client.login()
294 except OpenBaoError as exc:
295 sys.stderr.write(f"rot_keystore: OpenBao unavailable ({exc}); using local store\n")
296 else:
297 return OpenBaoBackend(client)
298 return LocalBackend()
299
300
301def _print_history(rows: list[dict]) -> None:
302 if not rows:
303 print("(no versions stored yet)")
304 return
305 for r in rows:
306 print(
307 f"v{r['version']:<3} {r['fingerprint']} {r['created_at']} "
308 f"{r['git_commit']} {r.get('note') or '-'}"
309 )
310
311
312def _keygen(key_path: Path, pubkey_c: Path) -> None:
313 subprocess.run( # noqa: S603 -- current interpreter runs the repository-owned signer
314 [
315 sys.executable,
316 str(_ROT_SIGN),
317 "keygen",
318 "--key",
319 str(key_path),
320 "--pubkey-c",
321 str(pubkey_c),
322 ],
323 check=True,
324 )
325 key_path.chmod(0o600)
326
327
328def cmd_store(args: argparse.Namespace) -> int:
329 """Persist an existing key file as a new version, tagging it as it goes.
330
331 Tags are computed here rather than by the backend, so a key stored locally
332 and the same key stored in the vault carry identical fingerprint, algorithm
333 and git_commit metadata -- which is what lets the two histories be compared.
334
335 Exits (rather than returning non-zero) when the key file is absent: there
336 is nothing partial to report.
337 """
338 backend = select_backend(args.backend)
339 key = Path(args.key)
340 if not key.exists():
341 sys.exit(f"key not found: {key}")
342 version = backend.store(key.read_text(encoding="ascii"), _tags(key, args.note))
343 print(f"stored {key} as version {version} in {backend.name}")
344 return 0
345
346
347def cmd_get(args: argparse.Namespace) -> int:
348 """Recover a stored private key to a file or to stdout.
349
350 ``--out`` writes 0600; without it the PEM goes to stdout, which is the
351 pipe-friendly path and deliberately leaves protecting the key to the
352 caller (redirecting it into a world-readable file is the caller's choice
353 to make, not something this can detect).
354 """
355 backend = select_backend(args.backend)
356 pem = backend.get(args.version)
357 if args.out:
358 out = Path(args.out)
359 out.write_text(pem, encoding="ascii")
360 out.chmod(0o600)
361 print(f"wrote {args.version or 'latest'} to {out} (mode 0600)")
362 else:
363 sys.stdout.write(pem)
364 return 0
365
366
367def cmd_history(args: argparse.Namespace) -> int:
368 """List every version in the active backend with its tags, oldest first.
369
370 Reports only the backend actually selected -- it does not merge the local
371 and vault histories, which are independent stores whose version numbers
372 are not comparable. Pass ``--backend`` twice over to see both.
373 """
374 backend = select_backend(args.backend)
375 _print_history(backend.history())
376 return 0
377
378
379def cmd_status(args: argparse.Namespace) -> int:
380 """Print which backend auto-selection resolved to, and its newest version.
381
382 The quickest way to answer "would a rekey right now write to the vault or
383 to my local store?" -- worth checking first, since that decision depends
384 on live vault reachability and so can differ between two runs on one
385 machine.
386 """
387 backend = select_backend(args.backend)
388 rows = backend.history()
389 print(f"backend: {backend.name}")
390 if rows:
391 latest = rows[-1]
392 print(f"latest: v{latest['version']} {latest['fingerprint']} {latest['created_at']}")
393 else:
394 print("latest: (none stored)")
395 return 0
396
397
398def cmd_rekey(args: argparse.Namespace) -> int:
399 """Generate a new signing key, preserving the outgoing one, and install it.
400
401 Ordering is the safety property, and it is why this is one command rather
402 than a documented sequence: the CURRENT working key is stored first, before
403 anything is generated or overwritten. Every later step can fail without
404 having destroyed a key, whereas generating first and backing up afterwards
405 has a window in which the only copy of the old key is the file about to be
406 overwritten.
407
408 The new keypair is generated inside a temporary directory that is removed
409 on exit, so the private key exists in exactly two places afterwards: the
410 backend, and the working-key path.
411
412 ``--patch`` additionally rewrites the public key baked into ra8_rot.c. That
413 is a source change, and after it the device must be re-flashed -- the new
414 public key is then the only one it will trust, so a device left on the old
415 image will reject everything signed with the new key.
416 """
417 backend = select_backend(args.backend)
418 working = Path(args.out)
419 # 1. Preserve the outgoing working key first, so no key is ever lost.
420 if working.exists():
421 v = backend.store(working.read_text(encoding="ascii"), _tags(working, "superseded key"))
422 print(f"backed up current working key as version {v} in {backend.name}")
423 # 2. Generate the new keypair into a temp dir.
424 with tempfile.TemporaryDirectory() as td:
425 new_key = Path(td) / "rot-new.pem"
426 pubkey_c = Path(td) / "rot-new-pubkey.h"
427 _keygen(new_key, pubkey_c)
428 tags = _tags(new_key, args.note or "rekey")
429 version = backend.store(new_key.read_text(encoding="ascii"), tags)
430 print(f"stored NEW key as version {version} ({tags['fingerprint']}) in {backend.name}")
431 # 3. Optionally provision the new public key into ra8_rot.c.
432 if args.patch:
433 subprocess.run( # noqa: S603 -- current interpreter runs the repository-owned patcher
434 [sys.executable, str(_ROT_PATCH), str(_ROT_C), str(pubkey_c)],
435 check=True,
436 )
437 # 4. Install as the working key rot_sign.py uses.
438 working.write_text(new_key.read_text(encoding="ascii"), encoding="ascii")
439 working.chmod(0o600)
440 print(f"installed new working key at {working} (mode 0600)")
441 print("re-flash the device: the new public key is the only one it will trust.")
442 return 0
443
444
445def _build_parser() -> argparse.ArgumentParser:
446 p = argparse.ArgumentParser(description="Versioned, tagged root-of-trust key store.")
447 p.add_argument(
448 "--backend",
449 choices=("auto", "openbao", "local"),
450 default="auto",
451 help="key store backend (default: auto -- vault if reachable, else local)",
452 )
453 sub = p.add_subparsers(dest="cmd", required=True)
454
455 s = sub.add_parser("store", help="store a key file as a new version")
456 s.add_argument("--key", default=str(_WORKING_KEY), help="private-key PEM to store")
457 s.add_argument("--note", default="manual store", help="tag note for this version")
458 s.set_defaults(func=cmd_store)
459
460 g = sub.add_parser("get", help="recover a version's private key")
461 g.add_argument("--version", type=int, default=None, help="version number (default: latest)")
462 g.add_argument("--out", default=None, help="write PEM here 0600 (default: stdout)")
463 g.set_defaults(func=cmd_get)
464
465 h = sub.add_parser("history", help="list every stored version and its tags")
466 h.set_defaults(func=cmd_history)
467
468 st = sub.add_parser("status", help="show the active backend and latest version")
469 st.set_defaults(func=cmd_status)
470
471 rk = sub.add_parser("rekey", help="create a new key, store + tag it, install as working key")
472 rk.add_argument("--patch", action="store_true", help="also provision the pubkey into ra8_rot.c")
473 rk.add_argument("--note", default=None, help="tag note for the new version")
474 rk.add_argument("--out", default=str(_WORKING_KEY), help="working-key path to install to")
475 rk.set_defaults(func=cmd_rekey)
476 return p
477
478
479def main(argv: list[str]) -> int:
480 """CLI entry: dispatch to the selected subcommand."""
481 args = _build_parser().parse_args(argv)
482 try:
483 return int(args.func(args))
484 except (OpenBaoError, subprocess.CalledProcessError) as exc:
485 sys.stderr.write(f"rot_keystore: {exc}\n")
486 return 1
487
488
489if __name__ == "__main__":
490 sys.exit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298