railiance-platform/tests/openbao_restore_rehearsal.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

291 lines
15 KiB
Python

"""Synthetic-material rehearsal fixture for RPF-WP-0064-T04.
Everything here is generated per run: SSH admission key, age identity, Shamir
shares, root credentials and the KV value. Source containers run the pinned
OpenBao image with ``--network none`` and tmpfs storage only and are removed.
No real snapshot, key, share or cluster is touched.
"""
from __future__ import annotations
import importlib.util
import json
import os
import secrets
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
REHEARSAL_IMAGE = "quay.io/openbao/openbao:2.5.4"
def load(name: str, rel: str):
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
adapter = load("restore_adapter", "scripts/openbao-isolated-restore/restore_adapter.py")
VERIFIER = ROOT / "scripts/openbao-isolated-restore/verify-restore-receipt.py"
def sh(argv, **kw) -> subprocess.CompletedProcess:
kw.setdefault("capture_output", True)
kw.setdefault("timeout", 120)
return subprocess.run(argv, **kw)
def docker_status() -> tuple[bool, str]:
if not shutil.which("docker") or not shutil.which("age") or not shutil.which("ssh-keygen"):
return False, "docker/age/ssh-keygen unavailable"
if sh(["docker", "version", "--format", "{{.Server.Version}}"], timeout=20).returncode != 0:
return False, "docker daemon unavailable"
inspect = sh(["docker", "image", "inspect", REHEARSAL_IMAGE, "--format", "{{json .RepoDigests}}"], timeout=20)
if inspect.returncode != 0:
return False, f"{REHEARSAL_IMAGE} not pulled locally (make openbao-isolated-restore-image)"
digests = [d for d in json.loads(inspect.stdout) if d.startswith(adapter.IMAGE_REPOSITORY + "@")]
return (True, digests[0]) if digests else (False, "image has no repository digest")
def make_source_root(base: Path, mutate: dict[str, bytes] | None = None) -> tuple[Path, str]:
"""A clean git checkout holding exactly the adapter source closure."""
root = base / ("src-" + secrets.token_hex(3))
for name in adapter.SOURCE_PATHS:
target = root / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes((mutate or {}).get(name, (ROOT / name).read_bytes()))
env = {**os.environ, "GIT_AUTHOR_NAME": "rehearsal", "GIT_AUTHOR_EMAIL": "rehearsal@invalid",
"GIT_COMMITTER_NAME": "rehearsal", "GIT_COMMITTER_EMAIL": "rehearsal@invalid"}
for argv in (["git", "init", "-q"], ["git", "add", "-A"],
["git", "-c", "core.hooksPath=/dev/null", "commit", "-q", "-m", "rehearsal closure"]):
done = sh(argv, cwd=root, env=env)
assert done.returncode == 0, done.stderr
commit = sh(["git", "rev-parse", "HEAD"], cwd=root).stdout.decode().strip()
return root, commit
# The source mirrors production (rapp-openbao chart setNodeId: the pod name),
# built independently of the adapter's target config. A rehearsal with a target
# 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}},
"audit": {"file": {"file": {"description": "rehearsal mirror of production",
"options": {"file_path": PRODUCTION_AUDIT_PATH}}}}},
separators=(",", ":"))
class SyntheticBao:
"""A generated-material OpenBao in a networkless container (source side)."""
def __init__(self, image: str):
self.image = image
self.name = "rpf64-rehearsal-src-" + secrets.token_hex(4)
self.credential = ""
self.shares: list[str] = []
def __enter__(self):
argv = ["docker", "run", "--detach", "--pull=never", "--name", self.name, "--network", "none",
"--user", "100:1000", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges"]
for target, options in adapter.TMPFS.items():
argv += ["--tmpfs", f"{target}:{options}"]
argv += ["-e", "BAO_LOCAL_CONFIG=" + SOURCE_CONFIG, "-e", "BAO_ADDR=http://127.0.0.1:8200",
self.image, "server"]
assert sh(argv).returncode == 0
try:
return self._start()
except BaseException:
sh(["docker", "rm", "-f", "-v", self.name])
raise
def _start(self):
for _ in range(60):
if self.bao(["status", "-format=json"]).returncode in (0, 2):
break
time.sleep(0.5)
init = json.loads(self.bao(["operator", "init", "-key-shares=3", "-key-threshold=2",
"-format=json"], check=True).stdout)
self.shares, self.credential = init["unseal_keys_b64"], init["root_token"]
for share in self.shares[:2]:
self.bao(["write", "sys/unseal", "-"], body=json.dumps({"key": share}).encode(), check=True)
for _ in range(60):
leader = self.bao(["read", "-format=json", "sys/leader"])
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):
sh(["docker", "rm", "-f", "-v", self.name])
def bao(self, args, *, body: bytes = b"", auth: bool = False, check: bool = False):
script = 'IFS= read -r t; BAO_TOKEN="$t"; export BAO_TOKEN; exec "$@"' if auth else 'exec "$@"'
data = (self.credential.encode() + b"\n" + body) if auth else body
done = sh(["docker", "exec", "-i", self.name, "sh", "-c", script, "sh", "bao", *args], input=data)
if check:
assert done.returncode == 0, done.stderr.decode()[-400:]
return done
def put(self, path: str, payload: dict):
self.bao(["write", path, "-"], body=json.dumps(payload).encode(), auth=True, check=True)
def seed(self, *, kv_registration: bool = True, jwt_role: bool = True) -> None:
"""Mirror the real source shapes (T01) with generated values only."""
src = lambda rel: json.loads((ROOT / rel).read_bytes()) # noqa: E731
self.bao(["auth", "enable", "-path=keycape-services", "jwt"], auth=True, check=True)
self.bao(["auth", "enable", "-path=netkingdom", "oidc"], auth=True, check=True)
self.bao(["secrets", "enable", "-path=platform", "kv-v2"], auth=True, check=True)
self.put("auth/keycape-services/config",
{**src("openbao/auth/keycape-services-config.json"), "skip_jwks_validation": True})
if jwt_role:
self.put("auth/keycape-services/role/secrets-engine-openbao",
src("openbao/auth/secrets-engine-jwt-role.json"))
oidc = {k: v for k, v in src("openbao/auth/netkingdom-oidc-config.json").items() if k != "_note"}
self.put("auth/netkingdom/config", {**oidc, "oidc_client_secret": "synthetic-" + secrets.token_hex(8),
"skip_jwks_validation": True})
self.put("auth/netkingdom/role/platform-admin", src("openbao/auth/netkingdom-platform-admin-role.json"))
policy = (ROOT / "openbao/policies/secrets-engine-login-self.hcl").read_bytes()
self.bao(["policy", "write", "secrets-engine-login-self", "-"], body=policy, auth=True, check=True)
if kv_registration:
self.kv_value = "synthetic-kv-" + secrets.token_urlsafe(24)
self.put("platform/data/workloads/keycape/secrets-engine-openbao",
{"data": {"client_credential": self.kv_value}})
def facts(self) -> tuple[str, int]:
status = json.loads(self.bao(["read", "-format=json", "sys/seal-status"], check=True).stdout)["data"]
leader = json.loads(self.bao(["read", "-format=json", "sys/leader"], check=True).stdout)["data"]
return status["cluster_id"], leader["raft_applied_index"]
def snapshot(self) -> bytes:
done = self.bao(["operator", "raft", "snapshot", "save", "/dev/stdout"], auth=True, check=True)
assert done.stdout[:2] == b"\x1f\x8b"
return done.stdout
class Rehearsal:
"""Generated custody, admission signing and session plumbing."""
def __init__(self, image: str):
self.image = image
self.work = Path(tempfile.mkdtemp(prefix="rpf64-rehearsal-", dir="/dev/shm"))
os.chmod(self.work, 0o700)
self.anchor = self.work / "anchor"
self.anchor.mkdir(mode=0o700)
key = self.work / "founder_ed25519"
assert sh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "founder@rehearsal", "-f",
str(key)]).returncode == 0
self.ssh_key = key
pub = (self.work / "founder_ed25519.pub").read_text().strip()
(self.anchor / "allowed_signers").write_text(
f'founder@rehearsal namespaces="{adapter.NAMESPACE}" {pub}\n')
os.environ[adapter.ANCHOR_ENV] = str(self.anchor / "allowed_signers")
self.age_identity, self.age_recipient = self.age_pair()
self.secrets_seen: list[str] = [self.age_identity]
@staticmethod
def age_pair() -> tuple[str, str]:
out = sh(["age-keygen"]).stdout.decode().splitlines()
identity = next(line for line in out if line.startswith("AGE-SECRET-KEY-1"))
recipient = sh(["age-keygen", "-y"], input=identity.encode()).stdout.decode().strip()
return identity, recipient
def close(self) -> None:
os.environ.pop(adapter.ANCHOR_ENV, None)
shutil.rmtree(self.work, ignore_errors=True)
def snapshot_set(self, *, kv_registration=True, jwt_role=True) -> dict:
with SyntheticBao(self.image) as bao:
bao.seed(kv_registration=kv_registration, jwt_role=jwt_role)
cluster_id, index = bao.facts()
plain = bao.snapshot()
shares, root_credential = list(bao.shares), bao.credential
self.secrets_seen += [bao.credential, *shares, getattr(bao, "kv_value", "")]
cipher = sh(["age", "-r", self.age_recipient], input=plain).stdout
path = self.work / ("snapshot-" + secrets.token_hex(3) + ".age")
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as out:
out.write(cipher)
return {"path": path, "cipher": cipher, "plain_sha": adapter.sha256_bytes(plain), "cluster_id": cluster_id,
"index": index, "shares": shares, "root_credential": root_credential}
def admission(self, snap: dict, source_root: Path, commit: str, **override) -> dict:
now = adapter.now_utc()
adm = {
"schema": adapter.ADMISSION_SCHEMA, "namespace": adapter.NAMESPACE,
"attempt_id": "RPF-WP-0064-T04-rehearsal-" + secrets.token_hex(3), "phase": "A",
"phase_b_admitted": False, "material": "synthetic",
"not_before": adapter.iso(now.replace(second=0) - __import__("datetime").timedelta(minutes=1)),
"not_after": adapter.iso(now + __import__("datetime").timedelta(hours=1)),
"max_duration_seconds": adapter.MAX_DURATION_SECONDS, "abort_operator": "founder",
"image": self.image, "adapter_commit": commit,
"snapshot": {"ciphertext_sha256": adapter.sha256_bytes(snap["cipher"]),
"ciphertext_bytes": len(snap["cipher"]), "plaintext_sha256": snap["plain_sha"],
"age_recipient": self.age_recipient, "source_cluster_id": snap["cluster_id"],
"source_applied_index": snap["index"],
"source_receipt_sha256": adapter.sha256_bytes(b"synthetic-source-receipt" + os.urandom(8))},
"unseal": {"key_threshold": 2, "key_shares": 3},
"readback_credential": "generate-root-from-entered-shares",
"source_sha256": adapter.source_digests(source_root),
}
for key, value in override.items():
if key in adm["snapshot"]:
adm["snapshot"][key] = value
else:
adm[key] = value
return adm
def signed(self, adm: dict) -> tuple[Path, Path, str]:
raw = json.dumps(adm, indent=2, sort_keys=True).encode()
path = self.work / (adm["attempt_id"] + ".json")
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "wb") as out:
out.write(raw)
done = sh(["ssh-keygen", "-q", "-Y", "sign", "-f", str(self.ssh_key), "-n", adapter.NAMESPACE, str(path)])
assert done.returncode == 0, done.stderr
sig = Path(str(path) + ".sig")
os.chmod(sig, 0o600)
return path, sig, adapter.sha256_bytes(raw)
def drill_admission(self, snap: dict, source_root: Path, commit: str, **override) -> dict:
"""RPF-WP-0064-T10 drill admission: same snapshot facts, disjoint attempt id and fields."""
adm = self.admission(snap, source_root, commit, **override)
adm.update({"attempt_id": "RPF-WP-0064-T10-rehearsal-" + secrets.token_hex(3), "drill": "break-glass",
"snapshot_label": "openbao-raft-20261014t000000z-synthetic.snap.age",
"readback_credential": adapter.DRILL_READBACK})
return adm
def session(self, adm: dict, snap: dict, source_root: Path, answers: list, **kw):
path, sig, digest = self.signed(adm)
mode = kw.get("mode", adapter.PHASE_A)
loaded, _raw, signer, protected = adapter.load_admission(path, sig, digest, Path(os.environ[adapter.ANCHOR_ENV]),
mode)
queue = list(answers)
def prompt(_label: str) -> bytearray:
item = queue.pop(0)
value = item() if callable(item) else item
return bytearray(value.encode())
receipt = self.work / (adm["attempt_id"] + "-receipt.json")
session = adapter.Session(admission=loaded, admission_sha256=digest, signer=signer,
anchor_is_protected=protected, ciphertext=snap["path"], receipt_path=receipt,
root=source_root, prompt=prompt, say=lambda _m: None, **kw)
return session, receipt
@staticmethod
def verify(receipt: Path, source_root: Path, *extra: str) -> subprocess.CompletedProcess:
return sh(["python3", str(VERIFIER), "--receipt", str(receipt), "--source-root", str(source_root), *extra])
def leftover_containers() -> list[str]:
done = sh(["docker", "ps", "-a", "--format", "{{.Names}}", "--filter", "name=rpf64-"])
return [n for n in done.stdout.decode().split() if n]