Escrow a new MAS age identity in OpenBao and rehearse recovery from custody.

The 2026-11-02 identity was never registered. The founder chose to create a
new one inside custody at platform/recovery/mas/age-identity before T04.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4541@bnt-lap001
Assistant-Session: 2099b3aa-d7d9-49a9-ab4e-fa8b7d0e39e8
This commit is contained in:
codex 2026-11-03 08:10:33 +01:00
parent f3984e9b1d
commit 2bb8c37fbc
5 changed files with 344 additions and 28 deletions

View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Create the MAS evidence age identity inside OpenBao custody.
Run only inside the attended OpenBao platform-admin login:
warden access openbao-platform-admin-login --exec -- \
python3 scripts/mas_evidence_age_escrow.py
Generates one age X25519 identity in memory, writes it CAS=0 to
platform/recovery/mas/age-identity (fields AGE_PRIVATE_KEY, AGE_RECIPIENT),
reads it back, and proves it by an in-memory encrypt/decrypt round trip.
The private key is never written to a file, argv, stdout, stderr or receipt.
Refuses when the path already holds a value: a successor is a reviewed
rotation, not a second run. Stdout and stderr stay silent; the receipt holds
the public recipient, KV version and field names only.
"""
from __future__ import annotations
import json
import os
import secrets
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path("/home/worsch/railiance-platform")
RECEIPT_DIR = Path("/home/worsch/.local/state/railiance/receipts")
RECEIPT_NAME = "mas-evidence-age-escrow.json"
MOUNT = "platform"
PATH = "recovery/mas/age-identity"
FIELDS = ["AGE_PRIVATE_KEY", "AGE_RECIPIENT"]
BAO_ADDR = "http://127.0.0.1:18200"
class EscrowError(RuntimeError):
def __init__(self, code, detail=None):
super().__init__(code)
self.code, self.detail = code, detail
def _gate():
import importlib.util
spec = importlib.util.spec_from_file_location(
"mas_evidence_publisher_gate", ROOT / "scripts/mas_evidence_publisher_gate.py"
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def generate(run=subprocess.run):
"""Return (private_key_line, recipient) without touching the filesystem."""
made = run(["age-keygen"], capture_output=True, timeout=30)
if made.returncode:
raise EscrowError("keygen_failed")
lines = made.stdout.decode().splitlines()
keys = [line.strip() for line in lines if line.startswith("AGE-SECRET-KEY-1")]
if len(keys) != 1:
raise EscrowError("keygen_output_rejected")
recipient = derive(keys[0], run)
return keys[0], recipient
def derive(private_key, run=subprocess.run):
out = run(["age-keygen", "-y"], input=(private_key + "\n").encode(),
capture_output=True, timeout=30)
lines = out.stdout.decode().split()
if out.returncode or len(lines) != 1 or not lines[0].startswith("age1"):
raise EscrowError("recipient_derivation_failed")
return lines[0]
def round_trip(private_key, recipient, run=subprocess.run):
"""Encrypt a random probe to the recipient and decrypt it with the key from stdin."""
probe = secrets.token_bytes(32)
sealed = run(["age", "-r", recipient], input=probe, capture_output=True, timeout=30)
if sealed.returncode:
raise EscrowError("probe_encrypt_failed")
read_fd, write_fd = os.pipe()
try:
os.write(write_fd, (private_key + "\n").encode())
os.close(write_fd)
write_fd = None
opened = run(["age", "-d", "-i", f"/dev/fd/{read_fd}"], input=sealed.stdout,
capture_output=True, timeout=30, pass_fds=(read_fd,))
finally:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
if opened.returncode or opened.stdout != probe:
raise EscrowError("probe_round_trip_failed")
return True
class Bao:
def __init__(self, token, addr=BAO_ADDR):
self.token, self.addr = token, addr
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def call(self, method, path, body=None):
request = urllib.request.Request(
self.addr + "/v1/" + path,
data=None if body is None else json.dumps(body).encode(),
method=method,
headers={"X-Vault-Token": self.token, "Content-Type": "application/json"},
)
try:
with self.opener.open(request, timeout=20) as response:
raw = response.read(65537)
return response.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as exc:
return exc.code, {}
except (urllib.error.URLError, OSError):
raise EscrowError("openbao_request_failed") from None
def escrow(bao, generate_fn=generate, round_trip_fn=round_trip, derive_fn=derive):
status, _ = bao.call("GET", f"{MOUNT}/metadata/{PATH}")
if status == 200:
raise EscrowError("existing_identity_requires_rotation_review")
if status != 404:
raise EscrowError("metadata_precheck_failed", {"http_status": status})
private_key, recipient = generate_fn()
round_trip_fn(private_key, recipient)
status, body = bao.call("POST", f"{MOUNT}/data/{PATH}", {
"options": {"cas": 0},
"data": {"AGE_PRIVATE_KEY": private_key, "AGE_RECIPIENT": recipient},
})
version = (body.get("data") or {}).get("version") if isinstance(body, dict) else None
if status != 200 or version != 1:
raise EscrowError("escrow_write_failed", {"http_status": status})
status, body = bao.call("GET", f"{MOUNT}/data/{PATH}?version=1")
data = ((body.get("data") or {}).get("data")) if isinstance(body, dict) else None
if status != 200 or not isinstance(data, dict) or sorted(data) != FIELDS:
raise EscrowError("escrow_readback_failed", {"http_status": status})
stored_key = data["AGE_PRIVATE_KEY"]
matches = stored_key == private_key and data["AGE_RECIPIENT"] == recipient
if not matches or derive_fn(stored_key) != recipient:
raise EscrowError("escrow_readback_mismatch")
round_trip_fn(stored_key, recipient)
del private_key, stored_key, data
return {
"status": "escrowed",
"path": f"{MOUNT}/{PATH}",
"kv_version": 1,
"cas": 0,
"field_names": FIELDS,
"recipient": recipient,
"readback_matches": True,
"round_trip_from_custody": True,
"private_key_on_disk": False,
"values_recorded": False,
}
def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv)
sys.stdout = open(os.devnull, "w")
sys.stderr = open(os.devnull, "w")
try:
if args:
raise EscrowError("usage_refused")
gate = _gate()
addr = os.environ.get("BAO_ADDR") or os.environ.get("VAULT_ADDR") or BAO_ADDR
if addr.rstrip("/") != BAO_ADDR:
raise EscrowError("bao_addr_mismatch")
token, source = gate.parent_token()
if not token:
raise EscrowError("parent_token_" + source)
receipt = escrow(Bao(token))
except EscrowError as exc:
receipt = {"status": "failed", "code": exc.code, "values_recorded": False}
if exc.detail:
receipt["detail"] = exc.detail
except Exception as exc:
receipt = {"status": "failed", "code": "contained_escrow_failure",
"error_type": type(exc).__name__, "values_recorded": False}
RECEIPT_DIR.mkdir(mode=0o700, exist_ok=True)
path = RECEIPT_DIR / RECEIPT_NAME
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as out:
json.dump(receipt, out, indent=2, sort_keys=True)
return 0 if receipt.get("status") == "escrowed" else 71
if __name__ == "__main__":
sys.exit(main())

View file

@ -4,13 +4,14 @@
Run only inside the attended OpenBao platform-admin login:
warden access openbao-platform-admin-login --exec -- \
python3 scripts/mas_evidence_restore_rehearsal.py --identity /abs/path/to/identity
python3 scripts/mas_evidence_restore_rehearsal.py [--evidence NAME.json]
Independent credentials: the founder-held supplier key on the MAS IAM
application, not the node's workload pair. The object is fetched at the exact
version named in the T03 evidence receipt and checked against its hash.
The identity must derive to the recorded recipient before it is used. The
packet is decrypted into a private scratch directory, every file is checked
The age identity is read from OpenBao custody (IDENTITY_PATH) into memory
and must derive to the recorded recipient before it is used. It reaches age
through an inherited pipe, never a file or argv. The packet is decrypted into a private scratch directory, every file is checked
against the packet's own hashes, SQLite integrity is checked, and every event
line must parse. Nothing is restored onto the facility, no receiver is
enabled, and no mail is sent. Plaintext scratch files are removed after the
@ -38,6 +39,7 @@ RECIPIENT = (ROOT / "deploy/mas-backup/age-recipient").read_text().strip() if (
RECEIPT_DIR = Path("/home/worsch/.local/state/railiance/receipts")
SCRATCH_ROOT = Path("/home/worsch/.local/state/railiance/mas-restore-rehearsal")
RECEIPT_NAME = "mas-evidence-offsite-rehearsal.json"
IDENTITY_PATH = "platform/recovery/mas/age-identity"
def _load(name, path):
@ -60,18 +62,44 @@ class RehearsalError(RuntimeError):
self.code, self.detail = code, detail
def identity_matches(identity, recipient=None):
def _age_with_key(private_key, args, timeout=120, run=subprocess.run):
"""Run age with the identity on an inherited pipe: never a file or argv."""
read_fd, write_fd = os.pipe()
try:
os.write(write_fd, (private_key.strip() + "\n").encode())
os.close(write_fd)
write_fd = None
return run(["age", "-d", "-i", f"/dev/fd/{read_fd}", *args],
capture_output=True, timeout=timeout, pass_fds=(read_fd,))
finally:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
def identity_matches(private_key, recipient=None):
recipient = recipient or RECIPIENT
path = Path(identity)
if not path.is_absolute() or not path.is_file() or path.stat().st_mode & 0o077:
raise RehearsalError("private_absolute_identity_required")
result = subprocess.run(["age-keygen", "-y", str(path)], capture_output=True, timeout=30)
public = result.stdout.decode().strip().splitlines()
if not isinstance(private_key, str) or not private_key.strip().startswith("AGE-SECRET-KEY-1"):
raise RehearsalError("custody_identity_rejected")
result = subprocess.run(["age-keygen", "-y"], input=(private_key.strip() + "\n").encode(),
capture_output=True, timeout=30)
public = result.stdout.decode().split()
if result.returncode or not recipient or public[:1] != [recipient]:
raise RehearsalError("identity_recipient_mismatch")
return True
def evidence_path(name=None):
if name is None:
return EVIDENCE
if "/" in name or not name.endswith(".json") or name.startswith("."):
raise RehearsalError("evidence_name_refused")
path = ROOT / "docs/evidence" / name
if not path.is_file():
raise RehearsalError("evidence_missing")
return path
def offsite_receipt(path=EVIDENCE):
body = json.loads(path.read_text(encoding="utf-8"))
receipt = dict(body["receipt"])
@ -80,14 +108,11 @@ def offsite_receipt(path=EVIDENCE):
return receipt
def verify_restore(encrypted, identity, scratch):
def verify_restore(encrypted, private_key, scratch):
"""Decrypt, extract, and check. Returns counts and hashes; never values."""
scratch.mkdir(mode=0o700, parents=False)
packet = scratch / "packet.tar"
result = subprocess.run(
["age", "-d", "-i", str(identity), "-o", str(packet), str(encrypted)],
capture_output=True, timeout=120,
)
result = _age_with_key(private_key, ["-o", str(packet), str(encrypted)])
if result.returncode:
raise RehearsalError("decryption_failed")
os.chmod(packet, 0o600)
@ -132,10 +157,10 @@ def verify_restore(encrypted, identity, scratch):
return {"captured_at": inner.get("captured_at"), "image": inner.get("image"), "files": files}
def rehearse(identity, client, receipt, scratch_root=SCRATCH_ROOT, clock=time.monotonic):
def rehearse(private_key, client, receipt, scratch_root=SCRATCH_ROOT, clock=time.monotonic):
started = clock()
started_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
identity_matches(identity)
identity_matches(private_key)
scratch_root.mkdir(mode=0o700, parents=True, exist_ok=True)
run = scratch_root / started_at.replace(":", "")
run.mkdir(mode=0o700)
@ -143,7 +168,7 @@ def rehearse(identity, client, receipt, scratch_root=SCRATCH_ROOT, clock=time.mo
encrypted = run / "fetched.tar.age"
fetched = primary.retrieve(client, "mas", encrypted, receipt)
fetched_seconds = round(clock() - started, 2)
restored = verify_restore(encrypted, identity, run / "restored")
restored = verify_restore(encrypted, private_key, run / "restored")
finally:
shutil.rmtree(run, ignore_errors=True)
elapsed = round(clock() - started, 2)
@ -161,6 +186,7 @@ def rehearse(identity, client, receipt, scratch_root=SCRATCH_ROOT, clock=time.mo
"ciphertext_bytes": fetched["ciphertext_bytes"],
"fetch_verified": fetched["status"] == "retrieved_verified",
"credential": "supplier key on IAM application 25a4cbca (independent of the node workload pair)",
"identity_source": IDENTITY_PATH,
"identity_matches_recipient": True,
"restored": restored,
"fetch_seconds": fetched_seconds,
@ -176,6 +202,13 @@ def rehearse(identity, client, receipt, scratch_root=SCRATCH_ROOT, clock=time.mo
}
def _custody_identity():
data = gate._kv_data(IDENTITY_PATH)
if sorted(data) != ["AGE_PRIVATE_KEY", "AGE_RECIPIENT"] or data["AGE_RECIPIENT"] != RECIPIENT:
raise RehearsalError("custody_identity_rejected")
return data["AGE_PRIVATE_KEY"]
def _supplier_client():
pair = gate.supplier_pair(gate._kv_data(gate.SUPPLIER), gate.SUPPLIER)
scoped, _ = gate.scoped_supplier_pair(pair, gate.PROJECT_ID)
@ -188,11 +221,12 @@ def main(argv=None):
sys.stderr = open(os.devnull, "w")
path = RECEIPT_DIR / RECEIPT_NAME
try:
if len(args) != 2 or args[0] != "--identity":
if args not in ([],) and not (len(args) == 2 and args[0] == "--evidence"):
raise RehearsalError("usage_refused")
evidence = evidence_path(args[1] if args else None)
gate._ensure_boto()
RECEIPT_DIR.mkdir(mode=0o700, exist_ok=True)
result = rehearse(args[1], _supplier_client(), offsite_receipt())
result = rehearse(_custody_identity(), _supplier_client(), offsite_receipt(evidence))
except (RehearsalError, gate.GateError) as exc:
result = {"status": "failed", "code": exc.code, "values_recorded": False}
if exc.detail: