diff --git a/scripts/openbao-isolated-restore/restore_adapter.py b/scripts/openbao-isolated-restore/restore_adapter.py index cb1b970..ce5c9ca 100644 --- a/scripts/openbao-isolated-restore/restore_adapter.py +++ b/scripts/openbao-isolated-restore/restore_adapter.py @@ -155,6 +155,11 @@ TMPFS = { # The image declares VOLUMEs here; tmpfs overlays stop anonymous volumes. "/openbao/file": "rw,nosuid,nodev,noexec,size=1048576,uid=100,gid=1000,mode=0700", "/openbao/logs": "rw,nosuid,nodev,noexec,size=1048576,uid=100,gid=1000,mode=0700", + # Production declares a file audit device at /openbao/audit/openbao-audit.log + # (rapp-openbao values). The restored audit table re-enables it on unseal; if + # the path is unwritable OpenBao seals itself again (T07 attempts -01/-02). + # The log holds HMAC'd entries only and dies with the container. + "/openbao/audit": "rw,nosuid,nodev,noexec,size=16777216,uid=100,gid=1000,mode=0700", } DIGEST_RE = re.compile(r"^sha256:[a-f0-9]{64}$") IMAGE_RE = re.compile(r"^" + re.escape(IMAGE_REPOSITORY) + r"@(sha256:[a-f0-9]{64})$") diff --git a/tests/openbao_restore_rehearsal.py b/tests/openbao_restore_rehearsal.py index 74a0df7..5c9eac6 100644 --- a/tests/openbao_restore_rehearsal.py +++ b/tests/openbao_restore_rehearsal.py @@ -72,8 +72,13 @@ def make_source_root(base: Path, mutate: dict[str, bytes] | None = None) -> tupl # node id differing from this voter still restored and elected a leader, so the # node id alone did not explain T07 attempt -01; it stays aligned with production. PRODUCTION_NODE_ID = "openbao-0" +# It also declares production's file audit device (rapp-openbao values), so the +# snapshot's audit table re-enables it on the target after unseal. +PRODUCTION_AUDIT_PATH = "/openbao/audit/openbao-audit.log" SOURCE_CONFIG = json.dumps({**json.loads(adapter.SERVER_CONFIG), - "storage": {"raft": {"path": "/openbao/data", "node_id": PRODUCTION_NODE_ID}}}, + "storage": {"raft": {"path": "/openbao/data", "node_id": PRODUCTION_NODE_ID}}, + "audit": {"file": {"file": {"description": "rehearsal mirror of production", + "options": {"file_path": PRODUCTION_AUDIT_PATH}}}}}, separators=(",", ":")) @@ -115,6 +120,9 @@ class SyntheticBao: if leader.returncode == 0 and json.loads(leader.stdout)["data"].get("is_self"): break time.sleep(0.5) + audits = json.loads(self.bao(["audit", "list", "-format=json"], auth=True, check=True).stdout) + assert any(a.get("type") == "file" and a.get("options", {}).get("file_path") == PRODUCTION_AUDIT_PATH + for a in audits.values()), "source must carry production's file audit device" return self def __exit__(self, *exc): diff --git a/tests/test_openbao_isolated_restore_native.py b/tests/test_openbao_isolated_restore_native.py index d34d18a..a2b23fc 100644 --- a/tests/test_openbao_isolated_restore_native.py +++ b/tests/test_openbao_isolated_restore_native.py @@ -85,6 +85,19 @@ class NativeRehearsal(unittest.TestCase): self.assertEqual(out["reason"], "custody_identity_refused") self.assertEqual(out["network"]["docker_networks"], []) + def test_unwritable_audit_path_reseals_and_aborts(self): + # Regression for T07 attempts -01/-02: without /openbao/audit the restored + # file audit device fails after unseal and OpenBao seals itself again. + removed = R.adapter.TMPFS.pop("/openbao/audit") + try: + session, receipt = self.run_session() + out = self.finish(session, receipt) + finally: + R.adapter.TMPFS["/openbao/audit"] = removed + self.assertEqual(out["reason"], "leader_not_elected") + self.assertTrue(out["restored"]["unsealed_with_original_shares"]) + self.assertIs(out["restored"]["leader_wait"]["last_seal"]["sealed"], True) + def test_wrong_ciphertext_hash_aborts(self): session, receipt = self.run_session(adm_override={"ciphertext_sha256": R.adapter.sha256_bytes(b"other")}) self.assertEqual(self.finish(session, receipt)["reason"], "ciphertext_mismatch") diff --git a/workplans/RPF-WP-0064-isolated-original-openbao-restore.md b/workplans/RPF-WP-0064-isolated-original-openbao-restore.md index e24c756..4c15daf 100644 --- a/workplans/RPF-WP-0064-isolated-original-openbao-restore.md +++ b/workplans/RPF-WP-0064-isolated-original-openbao-restore.md @@ -390,6 +390,18 @@ The adapter now aligns the node id with production, waits up to 180 s, aborts as records bounded typed `sys/leader`/`sys/seal-status` fields and a redacted first error line in `restored.leader_wait`. Offline 19 and native 12 pass. A new `-02` admission is needed for the new adapter commit. +2026-10-24 attempt `-02` (admission `33c8aab2…75ab`, adapter `624843e`): two runs refused at the first prompt +(`custody_identity_refused`, clipboard content; the key file and a shape-only paste check were correct), then a +third run reached `aborted`, `leader_not_elected` after 236 s. Its `leader_wait` diagnostics showed the cause: +after the original shares unsealed the node, seal-status read `sealed: true` and `sys/leader` returned API errors +for 180 s. Production declares a file audit device at `/openbao/audit/openbao-audit.log` (rapp-openbao values); +the restored audit table re-enables it on unseal, the target had no writable `/openbao/audit`, and OpenBao sealed +itself again. Both `-02` aborted receipts (`cfb2c2af…`, `4c5cd362…`, `904a9d95…` incl. the refusals) verified +independently from a clean clone at `624843e`. Fix: an in-memory `/openbao/audit` on the target. The rehearsal +source now carries production's file audit device, and a native regression case without that mount reproduces the +reseal (`leader_not_elected`, `sealed: true`), while the full restore passes with it. Offline 19 and native 13 pass. +A `-03` admission is needed for the new adapter commit. + Run the admitted adapter once. The outcome is a T03 receipt verified independently. The receipt is kept outside Git and referenced by its hash. A missing object or failed check is a valid `refused` outcome; it is not repaired