"""RPF-WP-0064-T04 native rehearsal: real OpenBao containers, generated material only. Runs the pinned OpenBao image with ``--network none``. Skips (and says so) when Docker or the image is unavailable; a skip is not native coverage. The only mocked element is the network-interface abort, where a wrapper reports an extra network in ``docker container inspect`` because attaching a real network is out of bounds for this rehearsal. """ from __future__ import annotations import base64 import json import os import signal import stat import subprocess import sys import time import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import openbao_restore_rehearsal as R # noqa: E402 OK, IMAGE = R.docker_status() @unittest.skipUnless(OK, f"native rehearsal skipped: {IMAGE}") class NativeRehearsal(unittest.TestCase): @classmethod def setUpClass(cls): cls.h = R.Rehearsal(IMAGE) cls.root, cls.commit = R.make_source_root(cls.h.work) cls.full = cls.h.snapshot_set() cls.partial = cls.h.snapshot_set(kv_registration=False, jwt_role=False) @classmethod def tearDownClass(cls): cls.h.close() def tearDown(self): self.assertEqual([n for n in R.leftover_containers() if n.startswith("rpf64-restore-")], []) def run_session(self, snap=None, answers=None, root=None, commit=None, adm_override=None, **kw): snap = snap or self.full root, commit = root or self.root, commit or self.commit adm = self.h.admission(snap, root, commit, **(adm_override or {})) if answers is None: answers = [self.h.age_identity, snap["shares"][0], snap["shares"][2]] session, receipt = self.h.session(adm, snap, root, answers, **kw) return session, receipt def finish(self, session, receipt, root=None, expect="aborted"): out = session.execute() self.assertEqual(out["result"], expect, out["reason"]) self.assertEqual(stat.S_IMODE(receipt.stat().st_mode), 0o600) text = receipt.read_text() for value in self.h.secrets_seen: if value: self.assertNotIn(value, text) verified = R.Rehearsal.verify(receipt, root or self.root, "--expect-result", expect) self.assertEqual(verified.returncode, 0, verified.stdout) return out def test_restored_end_to_end(self): session, receipt = self.run_session() out = self.finish(session, receipt, expect="restored") self.assertEqual(out["reason"], "all_required_objects_match") self.assertTrue(all(r["present"] and r["source_match"] for r in out["objects"])) self.assertEqual(out["restored"]["cluster_id"], self.full["cluster_id"]) self.assertEqual(out["network"]["interfaces"], ["lo"]) self.assertTrue(out["network"]["egress_refused"]) self.assertTrue(out["teardown"]["readback_credential_revoked"]) self.assertTrue(out["restored"]["throwaway_init_invalidated"]) late = R.Rehearsal.verify(receipt, self.root, "--not-after", "2026-01-01T00:00:00Z") self.assertNotEqual(late.returncode, 0) # Independent source check: a checkout with one changed source refuses. other, _ = R.make_source_root(self.h.work, {"openbao/auth/secrets-engine-jwt-role.json": b"{}\n"}) self.assertNotEqual(R.Rehearsal.verify(receipt, other).returncode, 0) def test_wrong_age_identity_aborts_before_target(self): other, _ = R.Rehearsal.age_pair() session, receipt = self.run_session(answers=[other]) out = self.finish(session, receipt) 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") def test_wrong_plaintext_hash_aborts(self): session, receipt = self.run_session(adm_override={"plaintext_sha256": R.adapter.sha256_bytes(b"other")}) self.assertEqual(self.finish(session, receipt)["reason"], "plaintext_mismatch") def test_wrong_share_aborts_and_removes_target(self): wrong = base64.b64encode(os.urandom(33)).decode() session, receipt = self.run_session(answers=[self.h.age_identity, self.full["shares"][0], wrong]) out = self.finish(session, receipt) self.assertEqual(out["reason"], "unseal_refused") self.assertTrue(out["restored"]["sealed_after_restore"]) self.assertTrue(out["teardown"]["container_absent_verified"]) def test_missing_objects_refused_not_repaired(self): session, receipt = self.run_session(snap=self.partial) out = self.finish(session, receipt, expect="refused") self.assertEqual(out["reason"], "object_missing") missing = sorted(r["object"] for r in out["objects"] if not r["present"]) self.assertEqual(missing, ["kv_registration_metadata", "secrets_engine_jwt_role"]) def test_source_mismatch_refused(self): role = json.loads((R.ROOT / "openbao/auth/secrets-engine-jwt-role.json").read_bytes()) role["token_ttl"] = "10m" root, commit = R.make_source_root(self.h.work, { "openbao/auth/secrets-engine-jwt-role.json": json.dumps(role, indent=2).encode()}) session, receipt = self.run_session(root=root, commit=commit) out = self.finish(session, receipt, root=root, expect="refused") self.assertEqual(out["reason"], "object_mismatch") self.assertEqual([r["object"] for r in out["objects"] if not r["source_match"]], ["secrets_engine_jwt_role"]) def test_network_interface_present_aborts(self): # Mocked element: real container, inspect output reports an extra network. wrapper = self.h.work / "docker-extra-network" wrapper.write_text( "#!/usr/bin/env python3\nimport json,subprocess,sys\n" "a=sys.argv[1:]\nr=subprocess.run(['docker',*a],capture_output=True)\n" "out=r.stdout\n" "if a[:2]==['container','inspect'] and r.returncode==0:\n" " d=json.loads(out);d[0]['NetworkSettings']['Networks']['bridge']={};out=json.dumps(d).encode()\n" "sys.stdout.buffer.write(out);sys.stderr.buffer.write(r.stderr);sys.exit(r.returncode)\n") wrapper.chmod(0o700) session, receipt = self.run_session(docker=str(wrapper)) out = session.execute() self.assertEqual((out["result"], out["reason"]), ("aborted", "network_containment_failed")) self.assertEqual(out["network"]["docker_networks"], ["bridge", "none"]) self.assertTrue(out["teardown"]["container_absent_verified"]) # The verifier refuses a receipt claiming any non-"none" network. self.assertNotEqual(R.Rehearsal.verify(receipt, self.root).returncode, 0) def test_timeout_aborts(self): holder = {} def slow_share(): holder["s"].deadline = time.monotonic() - 1 return self.full["shares"][0] session, receipt = self.run_session(answers=[self.h.age_identity, slow_share, self.full["shares"][2]]) holder["s"] = session out = self.finish(session, receipt) self.assertEqual(out["reason"], "deadline_exceeded") def test_operator_abort_signal(self): def interrupt(): os.kill(os.getpid(), signal.SIGINT) time.sleep(1) return self.full["shares"][0] session, receipt = self.run_session(answers=[self.h.age_identity, interrupt]) out = self.finish(session, receipt) self.assertEqual(out["reason"], "operator_abort") self.assertTrue(out["teardown"]["container_absent_verified"]) # ---- RPF-WP-0064-T10 break-glass drill (generated material; the held token is synthetic) def run_drill(self, held: str, reentered: list[str]): adm = self.h.drill_admission(self.full, self.root, self.commit) answers = [self.h.age_identity, self.full["shares"][0], self.full["shares"][2], held, *reentered] return self.h.session(adm, self.full, self.root, answers, mode=R.adapter.DRILL_MODE) def test_break_glass_drill_with_valid_held_token(self): # The synthetic source's initial root token stands in for the founder's held token. session, receipt = self.run_drill(self.full["root_credential"], [self.full["shares"][1], self.full["shares"][0]]) out = self.finish(session, receipt, expect="drilled") self.assertEqual(out["held_root_token"], {"valid": True, "policies": ["root"], "ttl_zero": True, "expire_time_null": True, "orphan": True}) self.assertEqual(out["generate_root"]["policies"], ["root"]) self.assertTrue(out["generate_root"]["minimal_action_ok"]) self.assertEqual(out["revocation"], {"revoke_status": 204, "post_revoke_lookup": 403}) self.assertTrue(out["cancel_drill"]["started_then_cancelled"]) self.assertEqual(out["objects"], []) self.assertEqual(out["claim_limits"]["production_root_token_validity"]["snapshot_plaintext_sha256"], self.full["plain_sha"]) def test_break_glass_drill_with_fake_held_token(self): fake = "s." + base64.b32encode(os.urandom(15)).decode().lower()[:24] self.h.secrets_seen.append(fake) session, receipt = self.run_drill(fake, [self.full["shares"][0], self.full["shares"][1]]) out = self.finish(session, receipt, expect="drilled") self.assertIs(out["held_root_token"]["valid"], False) def test_break_glass_drill_wrong_reentered_share_aborts(self): wrong = base64.b64encode(os.urandom(33)).decode() session, receipt = self.run_drill(self.full["root_credential"], [self.full["shares"][0], wrong]) out = self.finish(session, receipt) self.assertEqual(out["reason"], "generate_root_failed") self.assertTrue(out["teardown"]["container_absent_verified"]) def reaper_case(self, *, kill_parent: bool, deadline_in: float) -> dict: session, receipt = self.run_session() parent = subprocess.Popen(["sleep", "300"]) R.sh(["docker", "run", "--detach", "--pull=never", "--name", session.name, "--network", "none", "--entrypoint", "sleep", IMAGE, "300"]).check_returncode() reaper = subprocess.Popen([sys.executable, str(R.ROOT / "scripts/openbao-isolated-restore/restore_adapter.py"), "--reaper"], stdin=subprocess.PIPE, start_new_session=True) header = {"parent_pid": parent.pid, "deadline_epoch": time.time() + deadline_in, "docker": "docker", "receipt_path": str(receipt), "base_receipt": session.receipt} reaper.stdin.write((json.dumps(header) + "\nC " + session.name + "\n").encode()) reaper.stdin.flush() if kill_parent: parent.kill() reaper.stdin.close() # parent death closes the pipe self.assertEqual(reaper.wait(timeout=120), 0) if not reaper.stdin.closed: reaper.stdin.close() parent.wait(timeout=20) out = json.loads(receipt.read_text()) self.assertEqual(out["teardown"]["performed_by"], "reaper") self.assertEqual(R.Rehearsal.verify(receipt, self.root, "--expect-result", "aborted").returncode, 0) return out def test_parent_loss_reaper_cleans_up(self): self.assertEqual(self.reaper_case(kill_parent=True, deadline_in=600)["reason"], "parent_lost") def test_hard_deadline_reaper_kills_session(self): self.assertEqual(self.reaper_case(kill_parent=False, deadline_in=3)["reason"], "deadline_exceeded") if __name__ == "__main__": unittest.main()