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
239 lines
12 KiB
Python
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()
|