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
This commit is contained in:
parent
05ff9f8678
commit
a2da4085e0
11 changed files with 867 additions and 49 deletions
|
|
@ -47,10 +47,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
for name in ("admission", "admission-sig", "admission-sha256", "ciphertext", "receipt"):
|
||||
parser.add_argument("--" + name, required=True)
|
||||
parser.add_argument("--mode", choices=(adapter.PHASE_A, adapter.DRILL_MODE), default=adapter.PHASE_A)
|
||||
opts = parser.parse_args(argv)
|
||||
try:
|
||||
adm, _raw, _signer, _protected = adapter.load_admission(
|
||||
Path(opts.admission), Path(opts.admission_sig), opts.admission_sha256, adapter.anchor_path())
|
||||
Path(opts.admission), Path(opts.admission_sig), opts.admission_sha256, adapter.anchor_path(), opts.mode)
|
||||
require(adm["material"] == "original", "admission_refused")
|
||||
adapter.check_window(adm)
|
||||
adapter.check_source_closure(adm, adapter.ROOT)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Isolated original-OpenBao restore adapter (RPF-WP-0064-T02, phase A only).
|
||||
"""Isolated original-OpenBao restore adapter (RPF-WP-0064-T02 phase A; T10 break-glass drill).
|
||||
|
||||
Restores one age-encrypted Raft snapshot into a disposable, networkless Docker
|
||||
container on the founder workstation, unseals it with the original Shamir
|
||||
|
|
@ -22,6 +22,15 @@ Outcomes: ``restored`` | ``refused`` (required object missing or not matching
|
|||
its source; never repaired) | ``aborted`` (any other failed check, timeout,
|
||||
operator abort). Only the launcher ``run-isolated-restore.sh`` is the attended
|
||||
entry point; ``Session`` is importable for the synthetic rehearsal tests.
|
||||
|
||||
``--mode break-glass-drill`` (RPF-WP-0064-T10, NK-WP-0055-T13) needs its own
|
||||
signed admission (``drill: "break-glass"``, attempt id ``RPF-WP-0064-T10-*``).
|
||||
It runs the same restore and unseal, forgets the unseal shares, then: checks a
|
||||
held root token with ``lookup-self`` only (never revoked or otherwise used);
|
||||
runs a fresh generate-root ceremony with a server OTP and two re-entered
|
||||
shares; does one read-only action (``sys/auth``); revokes the generated token
|
||||
(204) and confirms ``lookup-self`` is refused (403); starts and cancels one
|
||||
more attempt. Outcomes: ``drilled`` | ``aborted``. No object readback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -127,13 +136,27 @@ ALLOWED_READS = frozenset({"sys/seal-status", "sys/leader", "auth/token/lookup-s
|
|||
*(o["path"] for o in OBJECTS)})
|
||||
ALLOWED_WRITES = frozenset({"sys/unseal", "sys/generate-root/attempt", "sys/generate-root/update",
|
||||
"auth/token/revoke-self"})
|
||||
# --------------------------------------------------------------------------- T10 break-glass drill
|
||||
PHASE_A, DRILL_MODE = "phase-a", "break-glass-drill"
|
||||
DRILL = "break-glass"
|
||||
GENERATE_ROOT_ATTEMPT = "sys/generate-root/attempt"
|
||||
MINIMAL_ACTION_PATH = "sys/auth"
|
||||
# T10 extensions: generate-root status (GET) and cancel (DELETE); sys/auth and
|
||||
# auth/token/lookup-self are already allowed reads, sys/generate-root/update an allowed write.
|
||||
ALLOWED_READS = ALLOWED_READS | {GENERATE_ROOT_ATTEMPT}
|
||||
ALLOWED_DELETES = frozenset({GENERATE_ROOT_ATTEMPT})
|
||||
DRILL_READBACK = "generate-root-from-reentered-shares"
|
||||
DRILL_CLAIMS_NOT_MADE = ("production_root_token_validity", "production_generate_root_availability",
|
||||
"sso_down_access_path")
|
||||
DRILL_ABORT_REASONS = frozenset({"held_token_refused", "generate_root_failed", "minimal_action_failed",
|
||||
"revocation_failed", "cancel_drill_failed"})
|
||||
ABORT_REASONS = frozenset({
|
||||
"admission_refused", "preflight_refused", "custody_identity_refused", "ciphertext_mismatch",
|
||||
"decrypt_failed", "plaintext_mismatch", "target_start_failed", "network_containment_failed",
|
||||
"throwaway_init_failed", "restore_failed", "unseal_refused", "leader_not_elected", "cluster_mismatch",
|
||||
"applied_index_behind", "readback_credential_failed", "readback_failed", "deadline_exceeded",
|
||||
"operator_abort", "parent_lost", "internal_error",
|
||||
})
|
||||
}) | DRILL_ABORT_REASONS
|
||||
# Production runs the chart with setNodeId, so the snapshot's voter is the pod
|
||||
# name. The target takes the same id; a native rehearsal showed a different id
|
||||
# still elects a leader after a forced restore, so this is alignment, not the
|
||||
|
|
@ -167,6 +190,12 @@ AGE_IDENTITY_RE = re.compile(r"^AGE-SECRET-KEY-1[0-9A-Z]{58}$")
|
|||
AGE_RECIPIENT_RE = re.compile(r"^age1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||
SHARE_RE = re.compile(r"^(?:[A-Za-z0-9+/]{40,100}={0,2}|[a-f0-9]{60,130})$")
|
||||
ATTEMPT_RE = re.compile(r"^RPF-WP-0064-(?:T07|T04)-[A-Za-z0-9-]{3,64}$")
|
||||
DRILL_ATTEMPT_RE = re.compile(r"^RPF-WP-0064-T10-[A-Za-z0-9-]{3,64}$")
|
||||
# Snapshot name for the claim limit, e.g. openbao-raft-20261014T1532Z.snap.age.
|
||||
SNAPSHOT_LABEL_RE = re.compile(r"^(?=.*20\d{6})[a-z0-9][a-z0-9._-]{2,95}$")
|
||||
HELD_TOKEN_RE = re.compile(r"^[A-Za-z0-9._-]{16,256}$")
|
||||
POLICY_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
|
||||
HTTP_CODE_RE = re.compile(rb"Code: (\d{3})\.")
|
||||
COMMIT_RE = re.compile(r"^[a-f0-9]{40}$")
|
||||
DURATION_RE = re.compile(r"^(\d+)([smh]?)$")
|
||||
|
||||
|
|
@ -229,14 +258,23 @@ SNAPSHOT_FIELDS = {"ciphertext_sha256", "ciphertext_bytes", "plaintext_sha256",
|
|||
"source_cluster_id", "source_applied_index", "source_receipt_sha256"}
|
||||
|
||||
|
||||
def validate_admission(adm: Any) -> dict[str, Any]:
|
||||
"""Shape-only validation of the signed admission (no I/O)."""
|
||||
require(isinstance(adm, dict) and set(adm) == ADMISSION_FIELDS, "admission_refused")
|
||||
def validate_admission(adm: Any, mode: str = PHASE_A) -> dict[str, Any]:
|
||||
"""Shape-only validation of the signed admission (no I/O); phase-A and drill admissions are disjoint."""
|
||||
require(mode in (PHASE_A, DRILL_MODE), "admission_refused")
|
||||
drill = mode == DRILL_MODE
|
||||
fields = ADMISSION_FIELDS | {"drill", "snapshot_label"} if drill else ADMISSION_FIELDS
|
||||
require(isinstance(adm, dict) and set(adm) == fields, "admission_refused")
|
||||
require(adm["schema"] == ADMISSION_SCHEMA and adm["namespace"] == NAMESPACE, "admission_refused")
|
||||
require(isinstance(adm["attempt_id"], str) and ATTEMPT_RE.fullmatch(adm["attempt_id"]), "admission_refused")
|
||||
attempt_re = DRILL_ATTEMPT_RE if drill else ATTEMPT_RE
|
||||
require(isinstance(adm["attempt_id"], str) and attempt_re.fullmatch(adm["attempt_id"]), "admission_refused")
|
||||
require(adm["phase"] == "A" and adm["phase_b_admitted"] is False, "admission_refused")
|
||||
require(adm["material"] in ("original", "synthetic"), "admission_refused")
|
||||
require(adm["readback_credential"] == "generate-root-from-entered-shares", "admission_refused")
|
||||
require(adm["readback_credential"] == (DRILL_READBACK if drill else "generate-root-from-entered-shares"),
|
||||
"admission_refused")
|
||||
if drill:
|
||||
require(adm["drill"] == DRILL, "admission_refused")
|
||||
require(isinstance(adm["snapshot_label"], str) and SNAPSHOT_LABEL_RE.fullmatch(adm["snapshot_label"]),
|
||||
"admission_refused")
|
||||
nb, na = parse_iso(adm["not_before"]), parse_iso(adm["not_after"])
|
||||
require(nb < na and (na - nb).total_seconds() <= 24 * 3600, "admission_refused")
|
||||
dur = adm["max_duration_seconds"]
|
||||
|
|
@ -338,11 +376,11 @@ def read_owner_file(path: Path, limit: int) -> bytes:
|
|||
return raw
|
||||
|
||||
|
||||
def load_admission(admission: Path, signature: Path, admission_sha256: str,
|
||||
signers: Path) -> tuple[dict[str, Any], bytes, dict[str, str], bool]:
|
||||
def load_admission(admission: Path, signature: Path, admission_sha256: str, signers: Path,
|
||||
mode: str = PHASE_A) -> tuple[dict[str, Any], bytes, dict[str, str], bool]:
|
||||
raw = read_owner_file(admission, 64 * 1024)
|
||||
require(DIGEST_RE.fullmatch(admission_sha256) and sha256_bytes(raw) == admission_sha256, "admission_refused")
|
||||
adm = validate_admission(strict_json(raw))
|
||||
adm = validate_admission(strict_json(raw), mode)
|
||||
signer = verify_signature(raw, read_owner_file(signature, 8192), signers)
|
||||
protected = anchor_protected(signers)
|
||||
if adm["material"] == "original":
|
||||
|
|
@ -401,6 +439,18 @@ def wipe(buf: bytearray | None) -> None:
|
|||
buf[i] = 0
|
||||
|
||||
|
||||
def http_code(stderr: bytes) -> int | None:
|
||||
"""HTTP status from the bao CLI error text (``Code: 403.``); None if absent."""
|
||||
match = HTTP_CODE_RE.search(stderr)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def policy_names(value: Any) -> list[str]:
|
||||
"""Policy names only, sorted; anything not shaped like a policy name is dropped."""
|
||||
return sorted(p for p in (value if isinstance(value, list) else [])
|
||||
if isinstance(p, str) and POLICY_NAME_RE.fullmatch(p))[:32]
|
||||
|
||||
|
||||
def tty_prompt(label: str) -> bytearray:
|
||||
"""Hidden prompt on the controlling terminal; the value never touches argv/env/disk."""
|
||||
require(sys.stdin.isatty(), "preflight_refused")
|
||||
|
|
@ -415,7 +465,10 @@ class Session:
|
|||
def __init__(self, *, admission: dict[str, Any], admission_sha256: str, signer: dict[str, str],
|
||||
anchor_is_protected: bool, ciphertext: Path, receipt_path: Path, root: Path = ROOT,
|
||||
prompt: Callable[[str], bytearray] = tty_prompt, docker: str = "docker",
|
||||
say: Callable[[str], None] = lambda m: print(m, flush=True), start_reaper: bool = True):
|
||||
say: Callable[[str], None] = lambda m: print(m, flush=True), start_reaper: bool = True,
|
||||
mode: str = PHASE_A):
|
||||
require(mode in (PHASE_A, DRILL_MODE) and (mode == DRILL_MODE) == ("drill" in admission), "admission_refused")
|
||||
self.mode = mode
|
||||
self.adm, self.root, self.prompt, self.docker, self.say = admission, root, prompt, docker, say
|
||||
self.ciphertext, self.receipt_path, self.start_reaper = ciphertext, receipt_path, start_reaper
|
||||
self.name = "rpf64-restore-" + secrets.token_hex(6)
|
||||
|
|
@ -458,6 +511,22 @@ class Session:
|
|||
"kv_data_read": False, "no_secret_material_recorded": True,
|
||||
"claims_not_made": list(CLAIMS_NOT_MADE),
|
||||
}
|
||||
if mode == DRILL_MODE:
|
||||
self.receipt["claims_not_made"] += list(DRILL_CLAIMS_NOT_MADE)
|
||||
self.receipt.update({
|
||||
"drill": DRILL,
|
||||
"held_root_token": {"valid": None, "policies": [], "ttl_zero": None, "expire_time_null": None,
|
||||
"orphan": None},
|
||||
"generate_root": {"started": False, "shares_entered": 0, "complete": False, "otp_decode_ok": False,
|
||||
"policies": [], "minimal_action_path": MINIMAL_ACTION_PATH,
|
||||
"minimal_action_ok": False},
|
||||
"revocation": {"revoke_status": None, "post_revoke_lookup": None},
|
||||
"cancel_drill": {"started_then_cancelled": False},
|
||||
# The held-token result is valid only as of this snapshot, never for production now.
|
||||
"claim_limits": {"production_root_token_validity": {
|
||||
"as_of_snapshot": admission["snapshot_label"],
|
||||
"snapshot_plaintext_sha256": snap["plaintext_sha256"]}},
|
||||
})
|
||||
|
||||
# ---- infrastructure
|
||||
def remaining(self) -> float:
|
||||
|
|
@ -501,6 +570,10 @@ class Session:
|
|||
body = json.loads(done.stdout or b"{}")
|
||||
return body.get("data", body) if isinstance(body, dict) and isinstance(body.get("data"), dict) else body
|
||||
|
||||
def delete(self, path: str, reason: str = "internal_error") -> None:
|
||||
require(path in ALLOWED_DELETES, "internal_error")
|
||||
self.bao(["delete", path], reason=reason)
|
||||
|
||||
def tell_reaper(self, line: str) -> None:
|
||||
if self.reaper and self.reaper.stdin:
|
||||
try:
|
||||
|
|
@ -778,6 +851,109 @@ class Session:
|
|||
self.receipt["objects"] = rows
|
||||
return rows
|
||||
|
||||
# ---- T10 break-glass drill steps (after restore + unseal)
|
||||
def forget_unseal_shares(self) -> None:
|
||||
"""The drill re-enters shares for generate-root; the unseal entries are not reused."""
|
||||
for share in self.shares:
|
||||
wipe(share)
|
||||
self.shares.clear()
|
||||
|
||||
def drill_held_token(self) -> None:
|
||||
self.say("Drill 1/5: held root-token check (hidden prompt; lookup-self only, never revoked).")
|
||||
held = self.prompt("Held initial root token")
|
||||
try:
|
||||
require(HELD_TOKEN_RE.fullmatch(held.decode("ascii", "replace")), "held_token_refused")
|
||||
done = self.bao(["read", "-format=json", "auth/token/lookup-self"], credential=held.decode(),
|
||||
check=False, reason="held_token_refused")
|
||||
finally:
|
||||
wipe(held)
|
||||
record = self.receipt["held_root_token"]
|
||||
if done.returncode == 0:
|
||||
body = json.loads(done.stdout)
|
||||
data = body.get("data") if isinstance(body, dict) and isinstance(body.get("data"), dict) else {}
|
||||
record.update(valid=True, policies=policy_names(data.get("policies")),
|
||||
ttl_zero=type(data.get("ttl")) is int and data["ttl"] == 0,
|
||||
expire_time_null="expire_time" in data and data["expire_time"] is None,
|
||||
orphan=data["orphan"] if type(data.get("orphan")) is bool else None)
|
||||
body.clear()
|
||||
data = {}
|
||||
elif http_code(done.stderr) == 403:
|
||||
record.update(valid=False)
|
||||
else:
|
||||
raise Refused("held_token_refused")
|
||||
|
||||
def drill_generate_root(self) -> None:
|
||||
self.say("Drill 2/5: fresh generate-root ceremony; re-enter two shares (hidden prompts).")
|
||||
record = self.receipt["generate_root"]
|
||||
status = self.read(GENERATE_ROOT_ATTEMPT, reason="generate_root_failed") or {}
|
||||
require(status.get("started") is False, "generate_root_failed")
|
||||
attempt = self.write(GENERATE_ROOT_ATTEMPT, {}, reason="generate_root_failed")
|
||||
otp, nonce, encoded, update = attempt.get("otp"), attempt.get("nonce"), None, {}
|
||||
attempt.clear()
|
||||
require(isinstance(otp, str) and otp and isinstance(nonce, str) and nonce, "generate_root_failed")
|
||||
record["started"] = True
|
||||
for index in (1, 2):
|
||||
share = self.prompt(f"Re-enter unseal share {index} of 2 for generate-root")
|
||||
try:
|
||||
require(SHARE_RE.fullmatch(share.decode("ascii", "replace")), "generate_root_failed")
|
||||
update = self.write("sys/generate-root/update", {"key": share.decode(), "nonce": nonce},
|
||||
reason="generate_root_failed")
|
||||
finally:
|
||||
wipe(share)
|
||||
record["shares_entered"] = index
|
||||
encoded = update.get("encoded_token") or update.get("encoded_root_token") or encoded
|
||||
record["complete"] = update.get("complete") is True and bool(encoded)
|
||||
require(record["complete"], "generate_root_failed")
|
||||
try:
|
||||
raw = base64.b64decode(encoded + "=" * (-len(encoded) % 4))
|
||||
require(len(raw) == len(otp), "generate_root_failed")
|
||||
self.credential = bytes(a ^ b for a, b in zip(raw, otp.encode())).decode("ascii")
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise Refused("generate_root_failed") from exc
|
||||
finally:
|
||||
otp = nonce = encoded = None
|
||||
update.clear()
|
||||
record["otp_decode_ok"] = True
|
||||
lookup = self.read("auth/token/lookup-self", self.credential, reason="generate_root_failed") or {}
|
||||
record["policies"] = policy_names(lookup.get("policies"))
|
||||
lookup.clear()
|
||||
require(record["policies"] == ["root"], "generate_root_failed")
|
||||
|
||||
def drill_minimal_action(self) -> None:
|
||||
self.say("Drill 3/5: one read-only action (sys/auth metadata).")
|
||||
mounts = self.read(MINIMAL_ACTION_PATH, self.credential, reason="minimal_action_failed")
|
||||
ok = isinstance(mounts, dict) and isinstance(mounts.get("token/"), dict)
|
||||
self.receipt["generate_root"]["minimal_action_ok"] = ok
|
||||
require(ok, "minimal_action_failed")
|
||||
|
||||
def drill_revoke(self) -> None:
|
||||
self.say("Drill 4/5: revoke-self the generated token, then confirm lookup-self is refused.")
|
||||
record = self.receipt["revocation"]
|
||||
require("auth/token/revoke-self" in ALLOWED_WRITES, "internal_error")
|
||||
done = self.bao(["write", "-format=json", "auth/token/revoke-self", "-"], credential=self.credential,
|
||||
body=b"{}", check=False, reason="revocation_failed")
|
||||
# The CLI prints nothing for 204 No Content; any response body means another status.
|
||||
record["revoke_status"] = (204 if not done.stdout.strip() else 200) if done.returncode == 0 \
|
||||
else http_code(done.stderr)
|
||||
check = self.bao(["read", "-format=json", "auth/token/lookup-self"], credential=self.credential,
|
||||
check=False, reason="revocation_failed")
|
||||
record["post_revoke_lookup"] = 200 if check.returncode == 0 else http_code(check.stderr)
|
||||
revoked = record["revoke_status"] == 204 and record["post_revoke_lookup"] == 403
|
||||
self.receipt["teardown"]["readback_credential_revoked"] = revoked
|
||||
require(revoked, "revocation_failed")
|
||||
self.credential = None
|
||||
|
||||
def drill_cancel(self) -> None:
|
||||
self.say("Drill 5/5: start one more generate-root attempt and cancel it.")
|
||||
attempt = self.write(GENERATE_ROOT_ATTEMPT, {}, reason="cancel_drill_failed")
|
||||
started = attempt.get("started") is True
|
||||
attempt.clear()
|
||||
self.delete(GENERATE_ROOT_ATTEMPT, reason="cancel_drill_failed")
|
||||
status = self.read(GENERATE_ROOT_ATTEMPT, reason="cancel_drill_failed") or {}
|
||||
ok = started and status.get("started") is False and status.get("progress") == 0
|
||||
self.receipt["cancel_drill"]["started_then_cancelled"] = ok
|
||||
require(ok, "cancel_drill_failed")
|
||||
|
||||
def revoke(self) -> None:
|
||||
if not self.credential or not self.container_started:
|
||||
return
|
||||
|
|
@ -844,16 +1020,25 @@ class Session:
|
|||
self.start_target()
|
||||
self.restore()
|
||||
self.unseal_original()
|
||||
self.generate_credential()
|
||||
rows = self.readback()
|
||||
missing = [r for r in rows if not r["present"]]
|
||||
mismatch = [r for r in rows if r["present"] and not r["source_match"]]
|
||||
if missing:
|
||||
result, reason = "refused", "object_missing"
|
||||
elif mismatch:
|
||||
result, reason = "refused", "object_mismatch"
|
||||
if self.mode == DRILL_MODE:
|
||||
self.forget_unseal_shares()
|
||||
self.drill_held_token()
|
||||
self.drill_generate_root()
|
||||
self.drill_minimal_action()
|
||||
self.drill_revoke()
|
||||
self.drill_cancel()
|
||||
result, reason = "drilled", "break_glass_drill_complete"
|
||||
else:
|
||||
result, reason = "restored", "all_required_objects_match"
|
||||
self.generate_credential()
|
||||
rows = self.readback()
|
||||
missing = [r for r in rows if not r["present"]]
|
||||
mismatch = [r for r in rows if r["present"] and not r["source_match"]]
|
||||
if missing:
|
||||
result, reason = "refused", "object_missing"
|
||||
elif mismatch:
|
||||
result, reason = "refused", "object_mismatch"
|
||||
else:
|
||||
result, reason = "restored", "all_required_objects_match"
|
||||
except Refused as exc:
|
||||
result, reason = "aborted", exc.reason if exc.reason in ABORT_REASONS else "internal_error"
|
||||
except Exception:
|
||||
|
|
@ -953,11 +1138,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
for name in ("admission", "admission-sig", "admission-sha256", "ciphertext", "receipt"):
|
||||
parser.add_argument("--" + name, required=True)
|
||||
parser.add_argument("--mode", choices=(PHASE_A, DRILL_MODE), default=PHASE_A)
|
||||
opts = parser.parse_args(args)
|
||||
os.umask(0o077)
|
||||
try:
|
||||
adm, _raw, signer, protected = load_admission(Path(opts.admission), Path(opts.admission_sig),
|
||||
opts.admission_sha256, anchor_path())
|
||||
opts.admission_sha256, anchor_path(), opts.mode)
|
||||
require(adm["material"] == "original", "admission_refused")
|
||||
check_source_closure(adm, ROOT)
|
||||
receipt = Path(opts.receipt)
|
||||
|
|
@ -966,12 +1152,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print(f"ERROR: {exc.reason}; no custody input requested.", file=sys.stderr)
|
||||
return 1
|
||||
session = Session(admission=adm, admission_sha256=opts.admission_sha256, signer=signer,
|
||||
anchor_is_protected=protected, ciphertext=Path(opts.ciphertext), receipt_path=receipt)
|
||||
anchor_is_protected=protected, ciphertext=Path(opts.ciphertext), receipt_path=receipt,
|
||||
mode=opts.mode)
|
||||
result = session.execute()
|
||||
verify = subprocess.run([sys.executable, str(ROOT / HERE / "verify-restore-receipt.py"), "--receipt",
|
||||
str(receipt), "--source-root", str(ROOT)], capture_output=True, timeout=60)
|
||||
print("Independent verifier: " + ("PASS" if verify.returncode == 0 else "FAIL"))
|
||||
return 0 if result["result"] in ("restored", "refused") and verify.returncode == 0 else 2
|
||||
return 0 if result["result"] in ("restored", "refused", "drilled") and verify.returncode == 0 else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Attended launcher: isolated original-OpenBao restore, phase A (RPF-WP-0064).
|
||||
# Attended launcher: isolated original-OpenBao restore, phase A or T10 break-glass drill (RPF-WP-0064).
|
||||
# Founder workstation only. Never railiance01, never a kubeconfig, no network.
|
||||
set +x
|
||||
set -euo pipefail
|
||||
|
|
@ -7,7 +7,9 @@ ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|||
cd -- "$ROOT"
|
||||
umask 077
|
||||
unset KUBECONFIG BAO_TOKEN VAULT_TOKEN BAO_ADDR VAULT_ADDR
|
||||
usage='Usage: run-isolated-restore.sh ADMISSION ADMISSION_SIG ADMISSION_SHA256 CIPHERTEXT RECEIPT_DIR'
|
||||
usage='Usage: run-isolated-restore.sh [--break-glass-drill] ADMISSION ADMISSION_SIG ADMISSION_SHA256 CIPHERTEXT RECEIPT_DIR'
|
||||
MODE=phase-a
|
||||
if [[ "${1:-}" == --break-glass-drill ]]; then MODE=break-glass-drill; shift; fi
|
||||
[[ $# == 5 ]] || { printf '%s\n' "$usage"; exit 1; }
|
||||
[[ "$3" =~ ^sha256:[a-f0-9]{64}$ ]] || { printf '%s\n' 'ERROR: exact sha256:<hex> admission digest required.'; exit 1; }
|
||||
for index in 1 2 4; do
|
||||
|
|
@ -19,13 +21,20 @@ done
|
|||
for tool in python3 age age-keygen docker ssh-keygen git; do
|
||||
command -v "$tool" >/dev/null || { printf 'ERROR: required tool unavailable: %s\n' "$tool"; exit 1; }
|
||||
done
|
||||
RECEIPT="$5/rpf64-restore-$(date -u +%Y%m%dT%H%M%SZ).json"
|
||||
python3 scripts/openbao-isolated-restore/preflight.py --admission "$1" --admission-sig "$2" \
|
||||
RECEIPT="$5/rpf64-${MODE}-$(date -u +%Y%m%dT%H%M%SZ).json"
|
||||
python3 scripts/openbao-isolated-restore/preflight.py --mode "$MODE" --admission "$1" --admission-sig "$2" \
|
||||
--admission-sha256 "$3" --ciphertext "$4" --receipt "$RECEIPT"
|
||||
printf '%s\n' 'Phase A only: networkless restore and metadata readback. No consumer continuity,'
|
||||
printf '%s\n' 'live JWT login, phase B network or owner acceptance is claimed.'
|
||||
printf '%s\n' 'You will be asked for the backup age identity, then two original unseal shares.'
|
||||
if [[ "$MODE" == break-glass-drill ]]; then
|
||||
printf '%s\n' 'Break-glass drill (RPF-WP-0064-T10) on an isolated, networkless restore. Never production.'
|
||||
printf '%s\n' 'Prompts: backup age identity, two unseal shares, the held initial root token (lookup-self only),'
|
||||
printf '%s\n' 'then two shares AGAIN for a fresh generate-root ceremony. Production root-token validity,'
|
||||
printf '%s\n' 'production generate-root availability and the SSO-down access path are not claimed.'
|
||||
else
|
||||
printf '%s\n' 'Phase A only: networkless restore and metadata readback. No consumer continuity,'
|
||||
printf '%s\n' 'live JWT login, phase B network or owner acceptance is claimed.'
|
||||
printf '%s\n' 'You will be asked for the backup age identity, then two original unseal shares.'
|
||||
fi
|
||||
printf '%s\n' 'Press Ctrl-C at any time to abort; the container is removed and an aborted receipt written.'
|
||||
python3 scripts/openbao-isolated-restore/restore_adapter.py --admission "$1" --admission-sig "$2" \
|
||||
python3 scripts/openbao-isolated-restore/restore_adapter.py --mode "$MODE" --admission "$1" --admission-sig "$2" \
|
||||
--admission-sha256 "$3" --ciphertext "$4" --receipt "$RECEIPT"
|
||||
printf 'Safe receipt: %s\n' "$RECEIPT"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ consistency, deadline, required teardown evidence, claims not made, refuses
|
|||
placeholders and anything shaped like a credential, share, key or KV value,
|
||||
and compares ``source_sha256`` against a clean checkout at ``adapter_commit``.
|
||||
|
||||
RPF-WP-0064-T10 break-glass drill receipts (``drill: "break-glass"``) carry a
|
||||
typed extension: held-token facts, generate-root ceremony facts, revocation
|
||||
status codes, the cancel drill and the snapshot the held-token result is
|
||||
limited to. Every extension value is an enumerated, typed fact; anything else
|
||||
(token, accessor, OTP, nonce, encoded token, share) is rejected.
|
||||
|
||||
Exit 0 = receipt accepted. Prints only PASS/FAIL and a reason code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -52,6 +58,17 @@ REQUIRED_OBJECTS = (
|
|||
"secrets_engine_jwt_role", "netkingdom_oidc_config", "platform_admin_role",
|
||||
"policy_secrets_engine_login_self", "kv_registration_metadata",
|
||||
)
|
||||
DRILL = "break-glass"
|
||||
DRILL_TOP = {"drill", "held_root_token", "generate_root", "revocation", "cancel_drill", "claim_limits"}
|
||||
HELD = {"valid", "policies", "ttl_zero", "expire_time_null", "orphan"}
|
||||
GENERATE_ROOT = {"started", "shares_entered", "complete", "otp_decode_ok", "policies", "minimal_action_path",
|
||||
"minimal_action_ok"}
|
||||
REVOCATION = {"revoke_status", "post_revoke_lookup"}
|
||||
DRILL_CLAIMS = {"production_root_token_validity", "production_generate_root_availability", "sso_down_access_path"}
|
||||
DRILL_ABORT = {"held_token_refused", "generate_root_failed", "minimal_action_failed", "revocation_failed",
|
||||
"cancel_drill_failed"}
|
||||
POLICY_NAME = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
|
||||
SNAPSHOT_LABEL = re.compile(r"^(?=.*20\d{6})[a-z0-9][a-z0-9._-]{2,95}$")
|
||||
CLAIMS = {"consumer_continuity", "live_jwt_login", "phase_b_network", "owner_acceptance",
|
||||
"recurring_restore_assurance", "production_redirect"}
|
||||
ABORT = {"admission_refused", "preflight_refused", "custody_identity_refused", "ciphertext_mismatch",
|
||||
|
|
@ -72,6 +89,9 @@ SECRET_SHAPES = (
|
|||
re.compile(r"(?<![a-f0-9])[a-f0-9]{32,}(?![a-f0-9])"),
|
||||
re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\."),
|
||||
)
|
||||
# Tokens without a prefix, accessors, OTPs, encoded tokens: a run of 20+ base62/64
|
||||
# characters mixing upper case, lower case and digits. Paths, ids and labels do not.
|
||||
MIXED_RUN = re.compile(r"[A-Za-z0-9+/=_]{20,}")
|
||||
BASE64_BLOB = re.compile(r"(?<![:A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}(?![A-Za-z0-9+/=])")
|
||||
|
||||
|
||||
|
|
@ -79,9 +99,14 @@ def base64_blob(text: str) -> bool:
|
|||
"""Random base64 (shares, keys) mixes cases and digits; repo paths do not."""
|
||||
return any(re.search(r"[A-Z]", m) and re.search(r"[a-z]", m) and re.search(r"[0-9+]", m)
|
||||
for m in BASE64_BLOB.findall(text))
|
||||
|
||||
|
||||
def mixed_run(text: str) -> bool:
|
||||
return any(re.search(r"[A-Z]", m) and re.search(r"[a-z]", m) and re.search(r"[0-9]", m)
|
||||
for m in MIXED_RUN.findall(text))
|
||||
PLACEHOLDERS = re.compile(r"(?i)replace-with|placeholder|todo|changeme|<[^>]*>|example\.invalid|YYYY")
|
||||
FORBIDDEN_KEYS = re.compile(r"(?i)^(?:data|value|values|secret|secrets|token|root_token|unseal_keys.*|keys?|"
|
||||
r"otp|nonce|encoded_.*|password|plaintext)$")
|
||||
r"otp|nonce|encoded_.*|password|plaintext|accessor|client_token|shares?|id)$")
|
||||
|
||||
|
||||
class Reject(Exception):
|
||||
|
|
@ -137,6 +162,8 @@ def check_content(receipt: dict) -> None:
|
|||
for shape in SECRET_SHAPES:
|
||||
need(not shape.search(stripped), "secret_shaped_value")
|
||||
need(not base64_blob(stripped), "secret_shaped_value")
|
||||
if path != "$.signer_fingerprint": # SHA256:<base64> is regex-checked above
|
||||
need(not mixed_run(stripped), "secret_shaped_value")
|
||||
|
||||
|
||||
def check_sources(receipt: dict, root: Path) -> None:
|
||||
|
|
@ -154,14 +181,58 @@ def check_sources(receipt: dict, root: Path) -> None:
|
|||
need("sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() == expected, "source_hash_mismatch")
|
||||
|
||||
|
||||
def check_drill(receipt: dict, result: str) -> None:
|
||||
"""Typed T10 extension; every value is an enumerated fact, never a credential."""
|
||||
need(receipt["drill"] == DRILL, "drill")
|
||||
held = receipt["held_root_token"]
|
||||
need(isinstance(held, dict) and set(held) == HELD, "drill_fields")
|
||||
need(all(held[k] in (True, False, None) for k in ("valid", "ttl_zero", "expire_time_null", "orphan")),
|
||||
"drill_fields")
|
||||
need(isinstance(held["policies"], list) and len(held["policies"]) <= 32
|
||||
and all(isinstance(p, str) and POLICY_NAME.fullmatch(p) for p in held["policies"]), "drill_fields")
|
||||
if held["valid"] is not True:
|
||||
need(held["policies"] == [] and held["ttl_zero"] is None and held["expire_time_null"] is None
|
||||
and held["orphan"] is None, "drill_inconsistent")
|
||||
gen = receipt["generate_root"]
|
||||
need(isinstance(gen, dict) and set(gen) == GENERATE_ROOT, "drill_fields")
|
||||
need(all(type(gen[k]) is bool for k in ("started", "complete", "otp_decode_ok", "minimal_action_ok")),
|
||||
"drill_fields")
|
||||
need(type(gen["shares_entered"]) is int and 0 <= gen["shares_entered"] <= 2, "drill_fields")
|
||||
need(isinstance(gen["policies"], list) and gen["policies"] in ([], ["root"]), "drill_fields")
|
||||
need(gen["minimal_action_path"] == "sys/auth", "drill_fields")
|
||||
rev = receipt["revocation"]
|
||||
need(isinstance(rev, dict) and set(rev) == REVOCATION, "drill_fields")
|
||||
need(all(rev[k] is None or (type(rev[k]) is int and 100 <= rev[k] <= 599) for k in REVOCATION), "drill_fields")
|
||||
cancel = receipt["cancel_drill"]
|
||||
need(isinstance(cancel, dict) and set(cancel) == {"started_then_cancelled"}
|
||||
and type(cancel["started_then_cancelled"]) is bool, "drill_fields")
|
||||
limits = receipt["claim_limits"]
|
||||
need(isinstance(limits, dict) and set(limits) == {"production_root_token_validity"}, "claim_limits")
|
||||
basis = limits["production_root_token_validity"]
|
||||
need(isinstance(basis, dict) and set(basis) == {"as_of_snapshot", "snapshot_plaintext_sha256"}
|
||||
and isinstance(basis["as_of_snapshot"], str) and SNAPSHOT_LABEL.fullmatch(basis["as_of_snapshot"])
|
||||
and basis["snapshot_plaintext_sha256"] == receipt["snapshot"]["plaintext_sha256"], "claim_limits")
|
||||
need(receipt["objects"] == [], "objects")
|
||||
if result == "drilled":
|
||||
need(held["valid"] in (True, False), "drill_inconsistent")
|
||||
need(gen["started"] and gen["shares_entered"] == 2 and gen["complete"] and gen["otp_decode_ok"]
|
||||
and gen["policies"] == ["root"] and gen["minimal_action_ok"], "drill_inconsistent")
|
||||
need(rev["revoke_status"] == 204 and rev["post_revoke_lookup"] == 403, "drill_inconsistent")
|
||||
need(cancel["started_then_cancelled"] is True, "drill_inconsistent")
|
||||
need(receipt["teardown"]["readback_credential_revoked"] is True, "drill_inconsistent")
|
||||
elif gen["policies"]:
|
||||
need(gen["otp_decode_ok"], "drill_inconsistent")
|
||||
|
||||
|
||||
def verify(receipt: object, *, source_root: Path, not_after: datetime | None = None,
|
||||
now: datetime | None = None) -> str:
|
||||
need(isinstance(receipt, dict) and set(receipt) == TOP, "schema_fields")
|
||||
drill = isinstance(receipt, dict) and "drill" in receipt
|
||||
need(isinstance(receipt, dict) and set(receipt) == (TOP | DRILL_TOP if drill else TOP), "schema_fields")
|
||||
need(receipt["schema"] == SCHEMA and receipt["admission_namespace"] == NAMESPACE, "schema")
|
||||
need(receipt["extends"] == "docs/openbao-restore-drill-evidence.example.json", "schema")
|
||||
need(receipt["phase"] == "A" and receipt["material"] in ("original", "synthetic"), "phase")
|
||||
need(isinstance(receipt["attempt_id"], str) and re.fullmatch(r"RPF-WP-0064-T0[47]-[A-Za-z0-9-]{3,64}",
|
||||
receipt["attempt_id"]), "attempt_id")
|
||||
attempt = r"RPF-WP-0064-T10-[A-Za-z0-9-]{3,64}" if drill else r"RPF-WP-0064-T0[47]-[A-Za-z0-9-]{3,64}"
|
||||
need(isinstance(receipt["attempt_id"], str) and re.fullmatch(attempt, receipt["attempt_id"]), "attempt_id")
|
||||
need(digest_ok(receipt["admission_sha256"]), "admission_digest")
|
||||
need(isinstance(receipt["signer_principal"], str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._@-]{0,127}",
|
||||
receipt["signer_principal"]), "signer")
|
||||
|
|
@ -175,8 +246,9 @@ def verify(receipt: object, *, source_root: Path, not_after: datetime | None = N
|
|||
and digest_ok(image.split("@", 1)[1]), "image")
|
||||
need(receipt["image_id"] is None or digest_ok(receipt["image_id"]), "image")
|
||||
need(receipt["no_secret_material_recorded"] is True and receipt["kv_data_read"] is False, "secret_policy")
|
||||
need(isinstance(receipt["claims_not_made"], list) and set(receipt["claims_not_made"]) == CLAIMS
|
||||
and len(receipt["claims_not_made"]) == len(CLAIMS), "claims_not_made")
|
||||
claims = CLAIMS | DRILL_CLAIMS if drill else CLAIMS
|
||||
need(isinstance(receipt["claims_not_made"], list) and set(receipt["claims_not_made"]) == claims
|
||||
and len(receipt["claims_not_made"]) == len(claims), "claims_not_made")
|
||||
|
||||
started, finished = parse_time(receipt["started_at"]), parse_time(receipt["finished_at"])
|
||||
deadline = receipt["deadline_seconds"]
|
||||
|
|
@ -237,7 +309,22 @@ def verify(receipt: object, *, source_root: Path, not_after: datetime | None = N
|
|||
names = [row["object"] for row in objects]
|
||||
|
||||
result, reason = receipt["result"], receipt["reason"]
|
||||
if result in ("restored", "refused"):
|
||||
if drill:
|
||||
check_drill(receipt, result)
|
||||
if drill and result == "drilled":
|
||||
need(reason == "break_glass_drill_complete", "result_inconsistent")
|
||||
need(snap["ciphertext_verified"] and snap["plaintext_verified"], "restore_inconsistent")
|
||||
need(restored["sealed_after_restore"] is True and restored["unsealed_with_original_shares"] is True
|
||||
and restored["cluster_id_match"] is True and restored["applied_index_not_behind"] is True
|
||||
and restored["throwaway_init_invalidated"] is True
|
||||
and restored["cluster_id"] == snap["source_cluster_id"]
|
||||
and restored["applied_index"] >= snap["source_applied_index"], "restore_inconsistent")
|
||||
need(network["network_mode"] == "none" and network["docker_networks"] == ["none"]
|
||||
and network["interfaces"] == ["lo"] and network["published_ports"] == [] and network["mounts"] == []
|
||||
and network["egress_refused"] is True, "network_not_contained")
|
||||
need(teardown["performed_by"] == "session", "teardown_missing")
|
||||
need(digest_ok(receipt["image_id"]), "image")
|
||||
elif result in ("restored", "refused") and not drill:
|
||||
need(tuple(names) == REQUIRED_OBJECTS, "objects_incomplete")
|
||||
need(snap["ciphertext_verified"] and snap["plaintext_verified"], "restore_inconsistent")
|
||||
need(restored["sealed_after_restore"] is True and restored["unsealed_with_original_shares"] is True
|
||||
|
|
@ -258,7 +345,7 @@ def verify(receipt: object, *, source_root: Path, not_after: datetime | None = N
|
|||
missing = any(not r["present"] for r in objects)
|
||||
need(not all_ok and reason == ("object_missing" if missing else "object_mismatch"), "result_inconsistent")
|
||||
elif result == "aborted":
|
||||
need(reason in ABORT, "result_inconsistent")
|
||||
need(reason in (ABORT | DRILL_ABORT if drill else ABORT), "result_inconsistent")
|
||||
need(not objects or len(objects) == len(REQUIRED_OBJECTS), "objects")
|
||||
if network["docker_networks"]:
|
||||
need(network["docker_networks"] == ["none"] and network["published_ports"] == [], "network_not_contained")
|
||||
|
|
@ -274,7 +361,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
parser.add_argument("--receipt", required=True)
|
||||
parser.add_argument("--source-root", required=True, help="clean checkout at the receipt's adapter_commit")
|
||||
parser.add_argument("--not-after", help="refuse after this UTC time (YYYY-MM-DDTHH:MM:SSZ)")
|
||||
parser.add_argument("--expect-result", choices=("restored", "refused", "aborted"))
|
||||
parser.add_argument("--expect-result", choices=("restored", "refused", "aborted", "drilled"))
|
||||
opts = parser.parse_args(argv)
|
||||
try:
|
||||
path = Path(opts.receipt)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue