87 lines
3.7 KiB
Python
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))
|