diff --git a/scripts/mas_evidence_age_escrow.py b/scripts/mas_evidence_age_escrow.py new file mode 100644 index 0000000..73dd283 --- /dev/null +++ b/scripts/mas_evidence_age_escrow.py @@ -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()) diff --git a/scripts/mas_evidence_restore_rehearsal.py b/scripts/mas_evidence_restore_rehearsal.py index 0a748f8..4a4a501 100644 --- a/scripts/mas_evidence_restore_rehearsal.py +++ b/scripts/mas_evidence_restore_rehearsal.py @@ -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: diff --git a/tests/test_mas_evidence_age_escrow.py b/tests/test_mas_evidence_age_escrow.py new file mode 100644 index 0000000..dad824b --- /dev/null +++ b/tests/test_mas_evidence_age_escrow.py @@ -0,0 +1,65 @@ +"""Age escrow: generated in memory, written CAS=0, read back and proven. Real age, fake OpenBao.""" + +import importlib.util +import json +import shutil +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif(not shutil.which("age-keygen"), reason="age required") +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("escrow", ROOT / "scripts/mas_evidence_age_escrow.py") +esc = importlib.util.module_from_spec(spec) +spec.loader.exec_module(esc) + + +class FakeBao: + def __init__(self, existing=False, corrupt=False): + self.store = {"x": 1} if existing else None + self.corrupt = corrupt + self.writes = [] + + def call(self, method, path, body=None): + if path == "platform/metadata/recovery/mas/age-identity": + return (200, {}) if self.store else (404, {}) + if method == "POST": + assert body["options"] == {"cas": 0} + self.writes.append(sorted(body["data"])) + self.store = dict(body["data"]) + return 200, {"data": {"version": 1}} + if method == "GET": + data = dict(self.store) + if self.corrupt: + data["AGE_RECIPIENT"] = "age1other" + return 200, {"data": {"data": data}} + raise AssertionError(path) + + +def test_escrow_writes_once_and_proves_from_custody(): + bao = FakeBao() + receipt = esc.escrow(bao) + assert receipt["status"] == "escrowed" and receipt["recipient"].startswith("age1") + assert bao.writes == [["AGE_PRIVATE_KEY", "AGE_RECIPIENT"]] + assert esc.derive(bao.store["AGE_PRIVATE_KEY"]) == receipt["recipient"] + assert "AGE-SECRET-KEY" not in json.dumps(receipt) + + +def test_existing_identity_is_never_replaced(): + bao = FakeBao(existing=True) + with pytest.raises(esc.EscrowError) as caught: + esc.escrow(bao) + assert caught.value.code == "existing_identity_requires_rotation_review" and bao.writes == [] + + +def test_readback_mismatch_fails(): + with pytest.raises(esc.EscrowError) as caught: + esc.escrow(FakeBao(corrupt=True)) + assert caught.value.code == "escrow_readback_mismatch" + + +def test_round_trip_rejects_a_foreign_key(): + key, _ = esc.generate() + _, other = esc.generate() + with pytest.raises(esc.EscrowError): + esc.round_trip(key, other) diff --git a/tests/test_mas_evidence_restore_rehearsal.py b/tests/test_mas_evidence_restore_rehearsal.py index f067fd2..36a3ac7 100644 --- a/tests/test_mas_evidence_restore_rehearsal.py +++ b/tests/test_mas_evidence_restore_rehearsal.py @@ -51,7 +51,8 @@ def packet(tmp_path): "destination": "s3://railiance-platform-pg-backup/" + KEY, "object_key": KEY, "version_id": "v1", "ciphertext_sha256": sealed["ciphertext_sha256"], "ciphertext_bytes": sealed["ciphertext_bytes"]} - return identity, recipient, data, receipt + key = [l for l in identity.read_text().splitlines() if l.startswith("AGE-SECRET-KEY-1")][0] + return key, recipient, data, receipt def test_rehearsal_restores_verifies_and_removes_plaintext(packet, tmp_path, monkeypatch): @@ -59,7 +60,7 @@ def test_rehearsal_restores_verifies_and_removes_plaintext(packet, tmp_path, mon monkeypatch.setattr(rh, "RECIPIENT", recipient) provider = Provider(data) scratch = tmp_path / "scratch" - result = rh.rehearse(str(identity), provider, receipt, scratch) + result = rh.rehearse(identity, provider, receipt, scratch) assert result["status"] == "passed" and result["fetch_verified"] and result["within_rto"] assert provider.calls == [(KEY, "v1")] files = result["restored"]["files"] @@ -75,7 +76,7 @@ def test_wrong_identity_refuses_before_fetch(packet, tmp_path, monkeypatch): monkeypatch.setattr(rh, "RECIPIENT", "age1someoneelse") provider = Provider(data) with pytest.raises(rh.RehearsalError) as caught: - rh.rehearse(str(identity), provider, receipt, tmp_path / "s") + rh.rehearse(identity, provider, receipt, tmp_path / "s") assert caught.value.code == "identity_recipient_mismatch" and provider.calls == [] @@ -83,15 +84,21 @@ def test_tampered_ciphertext_fails_fetch(packet, tmp_path, monkeypatch): identity, recipient, data, receipt = packet monkeypatch.setattr(rh, "RECIPIENT", recipient) with pytest.raises(ValueError): - rh.rehearse(str(identity), Provider(data[:-1] + b"X"), receipt, tmp_path / "s") + rh.rehearse(identity, Provider(data[:-1] + b"X"), receipt, tmp_path / "s") assert list((tmp_path / "s").iterdir()) == [] -def test_identity_file_must_be_private(packet, monkeypatch): - identity, recipient, _, _ = packet - identity.chmod(0o644) - with pytest.raises(rh.RehearsalError): - rh.identity_matches(str(identity), recipient) +def test_custody_value_must_be_an_age_identity(packet): + _, recipient, _, _ = packet + with pytest.raises(rh.RehearsalError) as caught: + rh.identity_matches("not-a-key", recipient) + assert caught.value.code == "custody_identity_rejected" + + +def test_evidence_name_stays_inside_docs_evidence(): + for bad in ("../x.json", "a/b.json", "x.txt"): + with pytest.raises(rh.RehearsalError): + rh.evidence_path(bad) def test_evidence_receipt_loads_the_committed_t03_object(): diff --git a/workplans/RPF-WP-0070-mas-evidence-recovery.md b/workplans/RPF-WP-0070-mas-evidence-recovery.md index 9ef47f9..1ed76d7 100644 --- a/workplans/RPF-WP-0070-mas-evidence-recovery.md +++ b/workplans/RPF-WP-0070-mas-evidence-recovery.md @@ -269,6 +269,25 @@ removes the plaintext. The workstation key `~/.config/age/railiance-backup.key` derives to a different recipient (`age1zvry…`), so the founder names the MAS identity path for the run. +2026-11-03: the founder decided the order is escrow first, then rehearse +from custody. The 2026-11-02 identity (`age17sxu…`) was never registered: +no catalog entry, no recorded path. The founder approved a new identity +created inside custody at `platform/recovery/mas/age-identity` (fields +`AGE_PRIVATE_KEY`, `AGE_RECIPIENT`; the same field name as the offsite +lane). `scripts/mas_evidence_age_escrow.py` runs in the attended +platform-admin login. It generates the key in memory and writes it CAS=0, +refusing if the path already holds a value. It reads the key back, checks +that it derives to the recipient, and proves it with an encrypt/decrypt +round trip, feeding the key to age through an inherited pipe. The +rehearsal now takes the identity from that path the same way, plus +`--evidence NAME.json` for the post-switch backup receipt. Then +`/etc/mas-backup/age-recipient` moves to the new recipient and one backup +runs before the rehearsal. The node reader policy cannot read the +recovery path. Retained objects sealed to `age17sxu…` (the 2026-11-02 +local capture and the 2026-11-03 06:47Z object) stay unreadable unless +that key turns up. They expire under the lifecycle, and nothing is +deleted. + ## Escrow the age identity and get owner acceptance ```task