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:
parent
f3984e9b1d
commit
2bb8c37fbc
5 changed files with 344 additions and 28 deletions
191
scripts/mas_evidence_age_escrow.py
Normal file
191
scripts/mas_evidence_age_escrow.py
Normal 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())
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
65
tests/test_mas_evidence_age_escrow.py
Normal file
65
tests/test_mas_evidence_age_escrow.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue