railiance-platform/tests/test_openbao_isolated_restore_native.py
codex a2da4085e0 RPF-WP-0064-T10: break-glass drill mode for the isolated restore adapter
Adds --mode break-glass-drill with its own signed admission (drill:
"break-glass", attempt id RPF-WP-0064-T10-*, snapshot_label). After the
phase A restore and unseal it checks the held root token with lookup-self
only, runs a fresh generate-root ceremony with re-entered shares, does one
read-only sys/auth action, revokes the generated token (204 then 403) and
drills a cancelled attempt. Phase A is unchanged. The verifier types the
receipt extension and rejects token, accessor, OTP, nonce, encoded-token
and share shapes. Offline 34 and native 16 pass. Founder runbooks for the
drill and the read-only production SSO-down walk are in the T10 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4515@bnt-lap001
Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
2026-10-28 00:02:10 +01:00

239 lines
12 KiB
Python

"""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()