From 048362e3ef5ce2a2e3679608e2b43bf397da3a75 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 14 Oct 2026 20:04:25 +0200 Subject: [PATCH] RPF-WP-0015-T03: snapshot lane accepts the escrow key in age identity-file format First attended snapshot run failed closed at verify: the stored AGE_PRIVATE_KEY carries the standard '# created'/'# public key' comment lines. Nothing uploaded. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 680842@bnt-lap001 Assistant-Session: 54eb9e8e-7d6b-4f0c-b7a6-a6db62075883 --- interfaces/RAILIANCE-WP-0024-owner-reviews.json | 2 +- scripts/openbao-reboot-snapshot.py | 17 ++++++++++++++++- tests/test_openbao_reboot_snapshot.py | 16 ++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/interfaces/RAILIANCE-WP-0024-owner-reviews.json b/interfaces/RAILIANCE-WP-0024-owner-reviews.json index 3706c8f..cf67e05 100644 --- a/interfaces/RAILIANCE-WP-0024-owner-reviews.json +++ b/interfaces/RAILIANCE-WP-0024-owner-reviews.json @@ -150,6 +150,6 @@ "scripts/audit-core-recovery-preflight.py": "d870d7763fabb9d01f72c157f82419257fd1bf9c2705e1661a98022b1caa29ba", "scripts/audit_core_synthetic_sender_lane.py": "608145f43b5adea110c4c73a2f567370fcfb603ef73ca8230775de9bf7687fa6", "scripts/t02-attended-window.py": "1faca370d14de9a55a3f04e55f6046105b5d3a606a4d79dadbe72c495ab75077", - "scripts/openbao-reboot-snapshot.py": "c29b403cfe64685346b5d08cfe8cc9836d39c5ad17ff021e7cfa5c1c14e0a59b" + "scripts/openbao-reboot-snapshot.py": "1370a40b75cfd89fe9cc60a9ba064414e6baa57349738fbb8bb26bc8804d96d8" } } diff --git a/scripts/openbao-reboot-snapshot.py b/scripts/openbao-reboot-snapshot.py index 1589f49..02bfa17 100755 --- a/scripts/openbao-reboot-snapshot.py +++ b/scripts/openbao-reboot-snapshot.py @@ -193,6 +193,21 @@ class Lane: def snapshot(self, target: Path) -> None: self.bao(["operator", "raft", "snapshot", "save", str(target)], label="save OpenBao Raft snapshot") + def lane_identity(self) -> str: + """The escrow key may be stored as a standard age identity file + (``# created``/``# public key`` comments plus one key line); keep only that line.""" + raw = self.bao(["kv", "get", "-field=AGE_PRIVATE_KEY", LANE_PATH], label="read offsite lane AGE_PRIVATE_KEY") + keys = [] + for line in raw.replace("\r\n", "\n").split("\n"): + line = line.strip() + if not line or line.startswith("#"): + continue + keys.append(line) + del raw + if len(keys) != 1 or not keys[0].startswith("AGE-SECRET-KEY-1"): + fail("offsite lane AGE_PRIVATE_KEY is unusable") + return keys[0] + def lane_field(self, field: str) -> str: value = self.bao(["kv", "get", f"-field={field}", LANE_PATH], label=f"read offsite lane {field}") value = value.strip() @@ -306,7 +321,7 @@ def run( stage.append("verify") url = lane.lane_field("NC_WEBDAV_URL") token = lane.lane_field("NC_WEBDAV_TOKEN") - identity = bytearray((lane.lane_field("AGE_PRIVATE_KEY") + "\n").encode()) + identity = bytearray((lane.lane_identity() + "\n").encode()) try: escrow_recipient = lane.recipient_of(bytes(identity)) if not hmac.compare_digest(escrow_recipient, recipient): diff --git a/tests/test_openbao_reboot_snapshot.py b/tests/test_openbao_reboot_snapshot.py index 8dde645..02c7a05 100644 --- a/tests/test_openbao_reboot_snapshot.py +++ b/tests/test_openbao_reboot_snapshot.py @@ -195,6 +195,22 @@ class SnapshotLaneTest(unittest.TestCase): now=datetime.now(UTC), max_age_hours=24) bad.unlink() + def test_escrow_key_in_age_identity_file_format_is_accepted(self): + recipient = snap.declared_recipient() + identity_file = f"# created: 2026-01-01T00:00:00Z\n# public key: {recipient}\n{self.identity}" + code, receipt = self.run_main(identity=identity_file) + self.assertEqual(code, 0) + self.assertNotIn("AGE-SECRET-KEY", self.receipt.read_text()) + + def test_escrow_key_with_two_keys_or_junk_is_unusable(self): + for bad in (f"{self.identity}\n{self.identity}", f"junk\n{self.identity}"): + code, record = self.run_main(identity=bad) + self.assertEqual(code, 1) + self.assertIn("AGE_PRIVATE_KEY is unusable", record["error"]) + self.assertNotIn("upload", self.lanes[-1].calls) + self.receipt.unlink() + self.assert_clean() + def test_custodian_recipient_requires_escrow_ref(self): _, recipient = throwaway_key() argv = ["--scenario-id", SCENARIO, "--receipt-file", str(self.receipt), "--custodian-recipient", recipient]