Files
felhom.eu/documentation/audits/release-2026-10-26/tools/escrow_ceremony.py
T

87 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""escrow_ceremony.py — the recovery-code ceremony on Tester 1 through the wizard's own calls (operator yes,
2026-10-25): login → GET /api/escrow/preflight → POST /api/escrow/start (password re-auth) → poll /api/escrow/status →
POST /api/escrow/claim (the ONE-SHOT code) → POST /api/escrow/confirm (the two words the agent names). The code is
NEVER printed or logged: it goes only into the 0600 file named by argv[1] (the caller hands it to `sec put`, which
shreds it). Run through `sec run TESTER1_PASSWORD -- python3 escrow_ceremony.py <codefile>`."""
import json, os, re, subprocess, sys, time
PW = os.environ["TESTER1_PASSWORD"]
SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=20", "root@192.168.0.154"]
HOST = "Host: felhom.enkicsifelhom.hu"
def run(args, inp=None):
return subprocess.run(args, input=inp, capture_output=True, text=True, timeout=300)
def base():
r = run(SSH + ["pct exec 9201 -- ip -4 -o addr show eth0"])
return "https://" + re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", r.stdout).group(1)
def say(*a):
print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
B = base()
h = run(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", HOST, "-X", "POST", "--data-urlencode", "password@-", f"{B}/login"], inp=PW).stdout
sess = re.search(r"felhom_session=[A-Za-z0-9._-]+", h).group(0)
page = run(["curl", "-sk", "-L", "-H", HOST, "-H", f"Cookie: {sess}", f"{B}/backup/escrow"]).stdout
csrf = re.search(r'<meta name="csrf-token" content="([^"]+)"', page).group(1)
def api(method, path, form=None, js=None):
a = ["curl", "-sk", "-H", HOST, "-H", f"Cookie: {sess}", "-H", f"X-CSRF-Token: {csrf}", "-X", method, "-w", "\n%{http_code}"]
inp = None
if form is not None:
a += ["--data-urlencode", f"_csrf={csrf}", "--data-urlencode", "password@-"]
inp = form
if js is not None:
a += ["-H", "Content-Type: application/json", "--data-binary", "@-"]
inp = json.dumps(js)
out = run(a + [f"{B}{path}"], inp=inp).stdout
body, _, code = out.rpartition("\n")
try:
return code, json.loads(body)
except ValueError:
return code, {"raw": body[:200]}
c, pre = api("GET", "/api/escrow/preflight")
say("preflight", c, json.dumps(pre, ensure_ascii=False)[:300])
c, st = api("POST", "/api/escrow/start", form=PW)
say("start", c, json.dumps(st, ensure_ascii=False)[:300])
if c != "200":
sys.exit(1)
KEEP = ("phase", "claimable", "claimed", "restic_pw_sealed", "uploaded", "awaiting_confirm", "key_fingerprint", "detail")
for _ in range(120):
time.sleep(2)
c, s = api("GET", "/api/escrow/status")
dd = s.get("data") or {}
if dd.get("claimable") is True or dd.get("phase") in ("failed", "unclaimed_void"):
break
say("status", c, json.dumps({k: dd.get(k) for k in KEEP}, ensure_ascii=False))
c, cl = api("POST", "/api/escrow/claim")
d = cl.get("data") or {}
code = d.get("recovery_code", "")
say("claim", c, "code received:", bool(code), "confirm_required:", d.get("confirm_required"), "positions:", d.get("verify_positions"))
if not code:
sys.exit(2)
fd = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
os.write(fd, code.encode())
os.close(fd)
words = code.split("-")
if d.get("confirm_required"):
p = d["verify_positions"]
c, cf = api("POST", "/api/escrow/confirm", js={"words": [words[p[0] - 1], words[p[1] - 1]]})
say("confirm", c, json.dumps(cf, ensure_ascii=False)[:300])
code, words = "", []
for _ in range(30):
time.sleep(2)
c, s = api("GET", "/api/escrow/status")
dd = s.get("data") or {}
if dd.get("uploaded") is True or dd.get("phase") in ("failed", "unconfirmed_void"):
break
say("final status", c, json.dumps({k: dd.get(k) for k in KEEP}, ensure_ascii=False))