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
372 lines
20 KiB
Python
372 lines
20 KiB
Python
"""RPF-WP-0064-T10 break-glass drill: offline tests with a fake OpenBao (no Docker).
|
|
|
|
The fake answers the bao CLI calls the drill makes, keeps generated tokens,
|
|
OTPs and nonces in memory only, and records which credential reached which
|
|
path, so the tests can show the held token is used for ``lookup-self`` only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import copy
|
|
import json
|
|
import secrets
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import test_openbao_isolated_restore as U # noqa: E402
|
|
|
|
A, V, R = U.A, U.V, U.R
|
|
LABEL = "openbao-raft-20261014t153200z.snap.age"
|
|
|
|
|
|
def b64share() -> str:
|
|
return base64.b64encode(secrets.token_bytes(33)).decode()
|
|
|
|
|
|
class FakeBao:
|
|
"""Just enough OpenBao for the drill steps; failure switches per test."""
|
|
|
|
def __init__(self, held_valid=True):
|
|
self.held = "s." + secrets.token_urlsafe(18)[:24]
|
|
self.held_valid = held_valid
|
|
self.generated: str | None = None
|
|
self.revoked = False
|
|
self.attempt: dict | None = None
|
|
self.calls: list[tuple[str, ...]] = []
|
|
self.held_paths: list[str] = []
|
|
self.generated_policies = ["root"]
|
|
self.revoke_body = b""
|
|
self.cancel_works = True
|
|
|
|
@staticmethod
|
|
def ok(body=b""):
|
|
return subprocess.CompletedProcess([], 0, body if isinstance(body, bytes) else json.dumps(body).encode(), b"")
|
|
|
|
@staticmethod
|
|
def code(status):
|
|
return subprocess.CompletedProcess([], 2, b"", f"Error: Error making API request.\n\nCode: {status}. "
|
|
"Errors:\n\n* permission denied\n".encode())
|
|
|
|
def __call__(self, args, *, credential=None, body=b"", limit=30, check=True, reason="internal_error"):
|
|
verb, path = args[0], args[-2] if args[-1] == "-" else args[-1]
|
|
self.calls.append((verb, path))
|
|
if credential is not None and credential == self.held:
|
|
self.held_paths.append(f"{verb} {path}")
|
|
done = self.answer(verb, path, credential, body)
|
|
if check and done.returncode != 0:
|
|
raise A.Refused(reason)
|
|
return done
|
|
|
|
def status(self):
|
|
att = self.attempt or {}
|
|
return {"started": bool(self.attempt), "progress": att.get("progress", 0), "required": 2,
|
|
"complete": False, "otp": "", "nonce": "", "encoded_token": "", "otp_length": 26}
|
|
|
|
def answer(self, verb, path, credential, body):
|
|
if path == "auth/token/lookup-self" and verb == "read":
|
|
if credential == self.held and self.held_valid:
|
|
return self.ok({"data": {"policies": ["root"], "ttl": 0, "expire_time": None, "orphan": True,
|
|
"id": self.held, "accessor": "Ab3" * 8}})
|
|
if credential and credential == self.generated and not self.revoked:
|
|
return self.ok({"data": {"policies": self.generated_policies, "ttl": 0, "expire_time": None,
|
|
"id": self.generated}})
|
|
return self.code(403)
|
|
if path == "sys/generate-root/attempt" and verb == "read":
|
|
return self.ok({"data": self.status()})
|
|
if path == "sys/generate-root/attempt" and verb == "write":
|
|
self.attempt = {"otp": secrets.token_urlsafe(30)[:26].replace("-", "a").replace("_", "b"),
|
|
"nonce": "0b0e" + secrets.token_hex(14), "progress": 0}
|
|
return self.ok({"data": {**self.status(), "otp": self.attempt["otp"], "nonce": self.attempt["nonce"]}})
|
|
if path == "sys/generate-root/attempt" and verb == "delete":
|
|
if self.cancel_works:
|
|
self.attempt = None
|
|
return self.ok(b"Success! Data deleted\n")
|
|
if path == "sys/generate-root/update":
|
|
payload = json.loads(body)
|
|
assert payload["nonce"] == self.attempt["nonce"]
|
|
self.attempt["progress"] += 1
|
|
if self.attempt["progress"] < 2:
|
|
return self.ok({"data": {**self.status(), "progress": 1}})
|
|
self.generated = "s." + secrets.token_urlsafe(18)[:24]
|
|
otp = self.attempt["otp"].encode()
|
|
encoded = base64.b64encode(bytes(a ^ b for a, b in zip(self.generated.encode(), otp))).decode()
|
|
self.attempt = None
|
|
return self.ok({"data": {"complete": True, "encoded_token": encoded, "progress": 2}})
|
|
if path == "sys/auth" and credential == self.generated and not self.revoked:
|
|
return self.ok({"data": {"token/": {"type": "token"}, "netkingdom/": {"type": "oidc"}}})
|
|
if path == "auth/token/revoke-self" and credential == self.generated:
|
|
self.revoked = True
|
|
return self.ok(self.revoke_body)
|
|
return self.code(403)
|
|
|
|
|
|
class DrillBase(U.Base):
|
|
def drill_admission(self, **override):
|
|
adm = self.admission(attempt_id="RPF-WP-0064-T10-unit-01", drill="break-glass", snapshot_label=LABEL,
|
|
readback_credential=A.DRILL_READBACK)
|
|
adm.update(override)
|
|
return adm
|
|
|
|
def session(self, fake, answers, **adm_override):
|
|
queue = list(answers)
|
|
self.prompts: list[str] = []
|
|
|
|
def prompt(label):
|
|
self.prompts.append(label)
|
|
return bytearray(queue.pop(0).encode())
|
|
adm = self.drill_admission(**adm_override)
|
|
session = A.Session(admission=adm, admission_sha256=U.digest("adm"),
|
|
signer={"principal": "founder@rehearsal", "fingerprint": "SHA256:" + "AbCd" * 10 + "AbC"},
|
|
anchor_is_protected=False, ciphertext=self.work / "none", receipt_path=self.work / "r",
|
|
root=self.root, prompt=prompt, say=lambda _m: None, start_reaper=False,
|
|
mode=A.DRILL_MODE)
|
|
session.bao = fake
|
|
return session
|
|
|
|
def run_drill(self, fake, answers=None):
|
|
answers = answers if answers is not None else [fake.held, b64share(), b64share()]
|
|
session = self.session(fake, answers)
|
|
session.drill_held_token()
|
|
session.drill_generate_root()
|
|
session.drill_minimal_action()
|
|
session.drill_revoke()
|
|
session.drill_cancel()
|
|
return session
|
|
|
|
def drill_receipt(self, session=None):
|
|
"""A complete ``drilled`` receipt from the adapter's own drill record plus a valid restore."""
|
|
session = session or self.run_drill(FakeBao())
|
|
base = self.receipt()
|
|
rc = copy.deepcopy(session.receipt)
|
|
for key in ("started_at", "finished_at", "elapsed_seconds", "image_id", "network", "restored", "snapshot"):
|
|
rc[key] = base[key]
|
|
rc["claim_limits"]["production_root_token_validity"]["snapshot_plaintext_sha256"] = \
|
|
rc["snapshot"]["plaintext_sha256"]
|
|
rc["image"] = base["image"]
|
|
rc["teardown"].update(container_removed=True, container_absent_verified=True)
|
|
rc.update(result="drilled", reason="break_glass_drill_complete")
|
|
return rc
|
|
|
|
|
|
class DrillAdmissionTests(DrillBase):
|
|
def test_valid_drill_admission(self):
|
|
A.validate_admission(self.drill_admission(), A.DRILL_MODE)
|
|
|
|
def test_phase_a_and_drill_admissions_are_disjoint(self):
|
|
with self.assertRaises(A.Refused):
|
|
A.validate_admission(self.drill_admission(), A.PHASE_A)
|
|
with self.assertRaises(A.Refused):
|
|
A.validate_admission(self.admission(), A.DRILL_MODE)
|
|
with self.assertRaises(A.Refused):
|
|
A.Session(admission=self.admission(), admission_sha256=U.digest("a"), signer={}, anchor_is_protected=False,
|
|
ciphertext=self.work, receipt_path=self.work / "x", mode=A.DRILL_MODE, start_reaper=False)
|
|
|
|
def test_drill_refusals(self):
|
|
cases = [{"drill": "phase-a"}, {"attempt_id": "RPF-WP-0064-T07-01"}, {"snapshot_label": "latest"},
|
|
{"snapshot_label": "Openbao-20261014"}, {"snapshot_label": "openbao-raft-snapshot"},
|
|
{"readback_credential": "generate-root-from-entered-shares"}, {"phase_b_admitted": True},
|
|
{"material": "original", "max_duration_seconds": 600}]
|
|
for override in cases:
|
|
with self.subTest(override=override), self.assertRaises(A.Refused):
|
|
A.validate_admission(self.drill_admission(**override), A.DRILL_MODE)
|
|
|
|
def test_allowed_surfaces(self):
|
|
self.assertIn("sys/generate-root/attempt", A.ALLOWED_READS)
|
|
self.assertIn("sys/generate-root/attempt", A.ALLOWED_WRITES)
|
|
self.assertIn("sys/generate-root/update", A.ALLOWED_WRITES)
|
|
self.assertEqual(A.ALLOWED_DELETES, frozenset({"sys/generate-root/attempt"}))
|
|
self.assertIn("auth/token/lookup-self", A.ALLOWED_READS)
|
|
self.assertIn("sys/auth", A.ALLOWED_READS)
|
|
self.assertFalse(any("/data/" in p for p in A.ALLOWED_READS))
|
|
self.assertEqual(A.MAX_DURATION_SECONDS, 2700)
|
|
listener = json.loads(A.SERVER_CONFIG)["listener"]["tcp"]
|
|
self.assertEqual(listener["address"], "127.0.0.1:8200")
|
|
self.assertIs(listener["disable_unauthed_generate_root_endpoints"], False)
|
|
with self.assertRaises(A.Refused):
|
|
self.session(FakeBao(), []).delete("sys/unseal")
|
|
|
|
|
|
class DrillStepTests(DrillBase):
|
|
def test_full_drill_records_only_facts(self):
|
|
fake = FakeBao()
|
|
shares = [b64share(), b64share()]
|
|
session = self.run_drill(fake, [fake.held, *shares])
|
|
rc = session.receipt
|
|
self.assertEqual(self.prompts, ["Held initial root token", "Re-enter unseal share 1 of 2 for generate-root",
|
|
"Re-enter unseal share 2 of 2 for generate-root"])
|
|
self.assertEqual(rc["held_root_token"], {"valid": True, "policies": ["root"], "ttl_zero": True,
|
|
"expire_time_null": True, "orphan": True})
|
|
self.assertEqual(rc["generate_root"], {"started": True, "shares_entered": 2, "complete": True,
|
|
"otp_decode_ok": True, "policies": ["root"],
|
|
"minimal_action_path": "sys/auth", "minimal_action_ok": True})
|
|
self.assertEqual(rc["revocation"], {"revoke_status": 204, "post_revoke_lookup": 403})
|
|
self.assertEqual(rc["cancel_drill"], {"started_then_cancelled": True})
|
|
self.assertIsNone(session.credential)
|
|
self.assertTrue(rc["teardown"]["readback_credential_revoked"])
|
|
# The held token reached lookup-self only: never revoked, never used for anything else.
|
|
self.assertEqual(fake.held_paths, ["read auth/token/lookup-self"])
|
|
self.assertIn(("delete", "sys/generate-root/attempt"), fake.calls)
|
|
text = json.dumps(rc)
|
|
for value in (fake.held, fake.generated, *shares):
|
|
self.assertNotIn(value, text)
|
|
self.assertEqual(rc["claim_limits"]["production_root_token_validity"],
|
|
{"as_of_snapshot": LABEL, "snapshot_plaintext_sha256": rc["snapshot"]["plaintext_sha256"]})
|
|
self.assertTrue(set(A.DRILL_CLAIMS_NOT_MADE) <= set(rc["claims_not_made"]))
|
|
|
|
def test_invalid_held_token_is_a_finding_not_an_abort(self):
|
|
fake = FakeBao(held_valid=False)
|
|
rc = self.run_drill(fake).receipt
|
|
self.assertEqual(rc["held_root_token"], {"valid": False, "policies": [], "ttl_zero": None,
|
|
"expire_time_null": None, "orphan": None})
|
|
self.assertEqual(self.verify(self.drill_receipt(self.run_drill(FakeBao(held_valid=False)))), "drilled")
|
|
|
|
def failing(self, fake, step, reason, answers=None):
|
|
session = self.session(fake, answers if answers is not None else [fake.held, b64share(), b64share()])
|
|
steps = [session.drill_held_token, session.drill_generate_root, session.drill_minimal_action,
|
|
session.drill_revoke, session.drill_cancel]
|
|
with self.assertRaises(A.Refused) as ctx:
|
|
for fn in steps[:step + 1]:
|
|
fn()
|
|
self.assertEqual(ctx.exception.reason, reason)
|
|
return session
|
|
|
|
def test_failures(self):
|
|
self.failing(FakeBao(), 0, "held_token_refused", ["not a token!", b64share(), b64share()])
|
|
fake = FakeBao()
|
|
fake.generated_policies = ["default"]
|
|
self.failing(fake, 1, "generate_root_failed")
|
|
self.failing(FakeBao(), 1, "generate_root_failed", [FakeBao().held, "short", b64share()])
|
|
fake = FakeBao()
|
|
fake.revoke_body = b'{"data": {}}'
|
|
session = self.failing(fake, 3, "revocation_failed")
|
|
self.assertFalse(session.receipt["teardown"]["readback_credential_revoked"])
|
|
fake = FakeBao()
|
|
fake.cancel_works = False
|
|
self.failing(fake, 4, "cancel_drill_failed")
|
|
|
|
def test_stale_attempt_refused(self):
|
|
fake = FakeBao()
|
|
fake.attempt = {"otp": "x", "nonce": "y", "progress": 1}
|
|
self.failing(fake, 1, "generate_root_failed")
|
|
|
|
|
|
class DrillVerifierTests(DrillBase):
|
|
def test_drilled_receipt_passes(self):
|
|
self.assertEqual(self.verify(self.drill_receipt()), "drilled")
|
|
|
|
def test_aborted_drill_receipt_passes(self):
|
|
session = self.session(FakeBao(), [])
|
|
rc = self.drill_receipt(session)
|
|
rc.update(result="aborted", reason="generate_root_failed")
|
|
self.assertEqual(self.verify(rc), "aborted")
|
|
|
|
def test_drill_and_phase_a_shapes_do_not_mix(self):
|
|
rc = self.drill_receipt()
|
|
rc.update(result="restored", reason="all_required_objects_match")
|
|
with self.assertRaises(V.Reject):
|
|
self.verify(rc)
|
|
phase_a = self.receipt()
|
|
phase_a.update(result="drilled", reason="break_glass_drill_complete")
|
|
with self.assertRaises(V.Reject):
|
|
self.verify(phase_a)
|
|
|
|
def test_adversarial_fixtures(self):
|
|
token = "s." + "Kq3x9ZbT7mW2pL8vR4nY6cJd"
|
|
hvs = "hvs.CAESI" + "Kq3x9ZbT7mW2pL8vR4nY6cJd" * 3
|
|
accessor = "Kq3x9ZbT7mW2pL8vR4nY6cJd"
|
|
otp = "Zq8T1mVb4Xw9Kc2Lp7Rn3Hs6Yd"
|
|
nonce = "0b0e6c2d-9f3a-4e1b-8c7d-5a6b7c8d9e0f"
|
|
encoded = base64.b64encode(bytes(range(40, 66))).decode()
|
|
share = base64.b64encode(bytes(range(1, 34))).decode()
|
|
hex_share = "ab01" * 17
|
|
gen = lambda **kw: (lambda r: r["generate_root"].update(**kw)) # noqa: E731
|
|
held = lambda **kw: (lambda r: r["held_root_token"].update(**kw)) # noqa: E731
|
|
cases = {
|
|
"token_value_policies": held(policies=[token]),
|
|
"token_in_path": lambda r: r.update(attempt_id="RPF-WP-0064-T10-" + token.replace(".", "-")),
|
|
"hvs_in_label": lambda r: r["claim_limits"]["production_root_token_validity"].update(as_of_snapshot=hvs),
|
|
"token_key": lambda r: r["held_root_token"].update(token=token),
|
|
"root_token_key": lambda r: r.update(root_token=token),
|
|
"accessor_key": held(accessor=accessor),
|
|
"accessor_value": gen(minimal_action_path=accessor),
|
|
"accessor_in_network": lambda r: r["network"].update(network_mode=accessor),
|
|
"otp_key": gen(otp=otp),
|
|
"otp_value": gen(policies=[otp]),
|
|
"otp_in_last_error": lambda r: r["restored"]["leader_wait"].update(last_error=otp) if
|
|
r["restored"]["leader_wait"] else r["network"].update(mounts=[otp]),
|
|
"nonce_key": gen(nonce=nonce),
|
|
"nonce_value": gen(minimal_action_path=nonce),
|
|
"encoded_token_key": gen(encoded_token=encoded),
|
|
"encoded_root_token_key": gen(encoded_root_token=encoded),
|
|
"encoded_value": lambda r: r["network"].update(mounts=[encoded]),
|
|
"share_key": gen(shares=[share]),
|
|
"share_value": gen(policies=[share]),
|
|
"share_in_cancel": lambda r: r.update(cancel_drill={"started_then_cancelled": share}),
|
|
"hex_share": lambda r: r["network"].update(mounts=[hex_share]),
|
|
"unseal_keys": lambda r: r.update(unseal_keys_b64=[share]),
|
|
"client_token": lambda r: r["revocation"].update(client_token=token),
|
|
}
|
|
for name, mutate in cases.items():
|
|
with self.subTest(name):
|
|
rc = self.drill_receipt()
|
|
mutate(rc)
|
|
with self.assertRaises(V.Reject):
|
|
self.verify(rc)
|
|
|
|
def test_inconsistent_drill_receipts(self):
|
|
cases = {
|
|
"revoke_200": (lambda r: r["revocation"].update(revoke_status=200), "drill_inconsistent"),
|
|
"lookup_after_revoke_ok": (lambda r: r["revocation"].update(post_revoke_lookup=200),
|
|
"drill_inconsistent"),
|
|
"one_share": (lambda r: r["generate_root"].update(shares_entered=1), "drill_inconsistent"),
|
|
"not_root": (lambda r: r["generate_root"].update(policies=["default"]), "drill_fields"),
|
|
"no_cancel": (lambda r: r["cancel_drill"].update(started_then_cancelled=False), "drill_inconsistent"),
|
|
"minimal_path": (lambda r: r["generate_root"].update(minimal_action_path="sys/mounts"), "drill_fields"),
|
|
"held_invalid_with_facts": (lambda r: r["held_root_token"].update(valid=False), "drill_inconsistent"),
|
|
"held_unchecked": (lambda r: r["held_root_token"].update(valid=None, policies=[], ttl_zero=None,
|
|
expire_time_null=None, orphan=None),
|
|
"drill_inconsistent"),
|
|
"claims_missing": (lambda r: r["claims_not_made"].remove("sso_down_access_path"), "claims_not_made"),
|
|
"snapshot_unnamed": (lambda r: r["claim_limits"]["production_root_token_validity"].update(
|
|
as_of_snapshot="latest"), "claim_limits"),
|
|
"snapshot_other_sha": (lambda r: r["claim_limits"]["production_root_token_validity"].update(
|
|
snapshot_plaintext_sha256=U.digest("other")), "claim_limits"),
|
|
"objects_read": (lambda r: r.update(objects=self.receipt()["objects"]), "objects"),
|
|
"phase_a_attempt": (lambda r: r.update(attempt_id="RPF-WP-0064-T07-01x"), "attempt_id"),
|
|
"unrevoked_teardown": (lambda r: r["teardown"].update(readback_credential_revoked=None),
|
|
"drill_inconsistent"),
|
|
"kv_data_read": (lambda r: r.update(kv_data_read=True), "secret_policy"),
|
|
"drill_reason_on_phase_a": (lambda r: r.update(result="aborted", reason="nope"), "result_inconsistent"),
|
|
}
|
|
for name, (mutate, code) in cases.items():
|
|
with self.subTest(name):
|
|
rc = self.drill_receipt()
|
|
mutate(rc)
|
|
with self.assertRaises(V.Reject) as ctx:
|
|
self.verify(rc)
|
|
self.assertEqual(str(ctx.exception), code)
|
|
|
|
def test_cli_accepts_drilled(self):
|
|
path = self.work / "drill.json"
|
|
path.write_text(json.dumps(self.drill_receipt()))
|
|
done = subprocess.run([sys.executable, str(R.VERIFIER), "--receipt", str(path), "--source-root",
|
|
str(self.root), "--expect-result", "drilled"], capture_output=True)
|
|
self.assertEqual(done.returncode, 0, done.stdout)
|
|
|
|
|
|
class DrillLauncherTests(DrillBase):
|
|
def test_launcher_mode_flag_refuses_before_prompt(self):
|
|
launcher = R.ROOT / "scripts/openbao-isolated-restore/run-isolated-restore.sh"
|
|
f = self.work / "f"
|
|
f.write_text("x")
|
|
out = subprocess.run(["bash", str(launcher), "--break-glass-drill", str(f), str(f), U.digest("x"), str(f),
|
|
str(self.work)], capture_output=True, stdin=subprocess.DEVNULL).stdout
|
|
self.assertIn(b"interactive terminal", out)
|
|
self.assertIn(b"--break-glass-drill", subprocess.run(["bash", str(launcher)], capture_output=True).stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|