4"""Versioned, tagged store for root-of-trust signing keys.
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):
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.
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.
23Every stored version is tagged with: fingerprint (SHA-256 of the public SPKI,
24matching rot_provision.sh), algorithm, created_at (UTC), git_commit, note.
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
36from __future__
import annotations
44from datetime
import UTC, datetime
45from pathlib
import Path
47sys.path.insert(0, str(Path(__file__).resolve().parent))
48from openbao_client
import OpenBaoClient, OpenBaoError
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"
59 return datetime.now(UTC).isoformat(timespec=
"seconds")
62def _git_commit() -> str:
65 [
"git",
"-C", str(_REPO_ROOT),
"rev-parse",
"--short",
"HEAD"],
70 except (subprocess.CalledProcessError, FileNotFoundError):
72 return out.stdout.strip()
or "unknown"
75def fingerprint(pem_path: Path) -> str:
76 """SHA-256 of the public SPKI, matching rot_provision.sh's fingerprint."""
77 spki = subprocess.run(
78 [
"openssl",
"ec",
"-in", str(pem_path),
"-pubout"],
82 dgst = subprocess.run(
83 [
"openssl",
"dgst",
"-sha256"],
88 return dgst.split()[-1]
91def _tags(pem_path: Path, note: str) -> dict[str, str]:
93 "fingerprint": fingerprint(pem_path),
94 "algorithm": _ALGORITHM,
95 "created_at": _now_iso(),
96 "git_commit": _git_commit(),
102 """File-backed versioned store for operators without the OpenBao vault."""
104 def __init__(self) -> None:
105 """Resolve the store directory and create it 0700 if it does not exist.
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.
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"
118 def name(self) -> str:
119 """Human-readable backend identity, including the resolved directory.
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.
125 return f
"local ({self.dir})"
127 def _load(self) -> list[dict]:
128 if not self.index.exists():
130 return json.loads(self.index.read_text(encoding=
"utf-8"))
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)
136 def store(self, pem: str, tags: dict[str, str]) -> int:
137 """Persist ``pem`` as a new version and return that version number.
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.
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.
149 Both the PEM and the index are written 0600.
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})
163 def get(self, version: int |
None) -> str:
164 """Return the private-key PEM for ``version``, or the newest when None.
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.
170 entries = self._load()
172 msg =
"local store is empty"
173 raise OpenBaoError(msg)
177 else next((e
for e
in entries
if int(e[
"version"]) == version),
None)
180 msg = f
"version {version} not in local store"
181 raise OpenBaoError(msg)
182 return (self.dir / entry[
"file"]).read_text(encoding=
"ascii")
184 def history(self) -> list[dict]:
185 """Every stored version, oldest first, as tag dicts.
187 Ordering is the contract the callers depend on: ``status`` and
188 ``store`` both read ``[-1]`` as "the current key".
194 """KV v2-backed versioned store; the vault's own versioning is the history."""
196 def __init__(self, client: OpenBaoClient) ->
None:
197 """Bind this backend to an already-constructed, configured vault client.
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.
204 self.path = os.environ.get(
"BAO_ROT_SECRET_PATH",
"ra8d2/rot-signing-key")
207 def name(self) -> str:
208 """Human-readable backend identity: vault address, mount and secret path.
210 Includes the address so an operator can see which vault was written
211 when several environments are configured.
213 return f
"openbao ({self.client.addr}, {self.client.mount}/{self.path})"
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.
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.
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.
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(
232 {
"purpose":
"RA8 root-of-trust signing key",
"algorithm": _ALGORITHM},
236 def get(self, version: int |
None) -> str:
237 """Return the private-key PEM for ``version``, or the newest when None.
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.
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)
249 def history(self) -> list[dict]:
250 """Every live vault version, oldest first, flattened to local-backend shape.
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.
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
261 meta = self.client.kv_metadata(self.path)
262 versions = meta.get(
"versions", {})
264 for vnum
in sorted(versions, key=int):
265 if versions[vnum].get(
"destroyed")
or versions[vnum].get(
"deletion_time"):
267 data = self.client.kv_get(self.path, version=int(vnum))
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",
""),
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)
291 if client.configured:
294 except OpenBaoError
as exc:
295 sys.stderr.write(f
"rot_keystore: OpenBao unavailable ({exc}); using local store\n")
297 return OpenBaoBackend(client)
298 return LocalBackend()
301def _print_history(rows: list[dict]) ->
None:
303 print(
"(no versions stored yet)")
307 f
"v{r['version']:<3} {r['fingerprint']} {r['created_at']} "
308 f
"{r['git_commit']} {r.get('note') or '-'}"
312def _keygen(key_path: Path, pubkey_c: Path) ->
None:
325 key_path.chmod(0o600)
328def cmd_store(args: argparse.Namespace) -> int:
329 """Persist an existing key file as a new version, tagging it as it goes.
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.
335 Exits (rather than returning non-zero) when the key file is absent: there
336 is nothing partial to report.
338 backend = select_backend(args.backend)
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}")
347def cmd_get(args: argparse.Namespace) -> int:
348 """Recover a stored private key to a file or to stdout.
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).
355 backend = select_backend(args.backend)
356 pem = backend.get(args.version)
359 out.write_text(pem, encoding=
"ascii")
361 print(f
"wrote {args.version or 'latest'} to {out} (mode 0600)")
363 sys.stdout.write(pem)
367def cmd_history(args: argparse.Namespace) -> int:
368 """List every version in the active backend with its tags, oldest first.
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.
374 backend = select_backend(args.backend)
375 _print_history(backend.history())
379def cmd_status(args: argparse.Namespace) -> int:
380 """Print which backend auto-selection resolved to, and its newest version.
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
387 backend = select_backend(args.backend)
388 rows = backend.history()
389 print(f
"backend: {backend.name}")
392 print(f
"latest: v{latest['version']} {latest['fingerprint']} {latest['created_at']}")
394 print(
"latest: (none stored)")
398def cmd_rekey(args: argparse.Namespace) -> int:
399 """Generate a new signing key, preserving the outgoing one, and install it.
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
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.
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.
417 backend = select_backend(args.backend)
418 working = Path(args.out)
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}")
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}")
434 [sys.executable, str(_ROT_PATCH), str(_ROT_C), str(pubkey_c)],
438 working.write_text(new_key.read_text(encoding=
"ascii"), encoding=
"ascii")
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.")
445def _build_parser() -> argparse.ArgumentParser:
446 p = argparse.ArgumentParser(description=
"Versioned, tagged root-of-trust key store.")
449 choices=(
"auto",
"openbao",
"local"),
451 help=
"key store backend (default: auto -- vault if reachable, else local)",
453 sub = p.add_subparsers(dest=
"cmd", required=
True)
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)
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)
465 h = sub.add_parser(
"history", help=
"list every stored version and its tags")
466 h.set_defaults(func=cmd_history)
468 st = sub.add_parser(
"status", help=
"show the active backend and latest version")
469 st.set_defaults(func=cmd_status)
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)
479def main(argv: list[str]) -> int:
480 """CLI entry: dispatch to the selected subcommand."""
481 args = _build_parser().parse_args(argv)
483 return int(args.func(args))
484 except (OpenBaoError, subprocess.CalledProcessError)
as exc:
485 sys.stderr.write(f
"rot_keystore: {exc}\n")
489if __name__ ==
"__main__":
490 sys.exit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.