#!/usr/bin/env python3 """Build an explicit offline recovery kit; never include an age private identity.""" from __future__ import annotations import argparse import json import os import shutil import subprocess import tarfile from pathlib import Path from ledger_archive import Refused, checkpoint, digest, private_directory, private_file ROOT = Path(__file__).resolve().parents[1] def prepare(evidence, ciphertext, destination): proof = json.loads(evidence.read_text()) transfer, recovery = proof["publication"], proof["application_recovery"] if (recovery.get("application_restore_verified") is not True or recovery.get("application") != "email-connect" or recovery.get("ciphertext_sha256") != transfer.get("ciphertext_sha256") or recovery.get("version_id") != transfer.get("version_id")): raise Refused("verified_recovery_point_required") private_file(ciphertext) if (digest(ciphertext) != transfer["ciphertext_sha256"] or ciphertext.stat().st_size != transfer["ciphertext_bytes"]): raise Refused("protected_ciphertext_binding_changed") destination.mkdir(mode=0o700) private_directory(destination) image = recovery["recovery_image"] inspected = subprocess.run(["docker", "image", "inspect", image], capture_output=True, timeout=30) if inspected.returncode: raise Refused("cached_recovery_image_required") image_id = json.loads(inspected.stdout)[0]["Id"] saved = subprocess.run(["docker", "image", "save", "--output", str(destination / "receiver-image.tar"), image], capture_output=True, timeout=180) if saved.returncode: raise Refused("recovery_image_export_failed") (destination / "receiver-image.tar").chmod(0o600) for name in ("ledger_archive.py", "backup-ledger.py", "rehearse-ledger.py"): shutil.copyfile(ROOT / "scripts" / name, destination / name) # This public module is bind-mounted into the UID10001 rehearsal. # The enclosing kit directory remains private; data stays mode 0600. (destination / name).chmod(0o644 if name == "ledger_archive.py" else 0o600) shutil.copyfile(ciphertext, destination / "ledger.tar.age") (destination / "ledger.tar.age").chmod(0o600) checkpoint(destination / "transfer-receipt.json", transfer) checkpoint(destination / "application-recovery.json", recovery) files = {p.name: {"sha256": digest(p), "bytes": p.stat().st_size} for p in destination.iterdir()} manifest = {"schema": "email-connect.ledger-recovery-kit.v1", "image": image, "image_id": image_id, "files": files, "private_identity_included": False, "protected_version": transfer["version_id"], "protected_ciphertext_sha256": transfer["ciphertext_sha256"]} checkpoint(destination / "manifest.json", manifest) with tarfile.open(destination / "kit.tar.gz", "w:gz") as archive: for name in sorted([*files, "manifest.json"]): archive.add(destination / name, arcname=name, recursive=False) (destination / "kit.tar.gz").chmod(0o600) receipt = {"status": "recovery_kit_prepared", "kit_sha256": digest(destination / "kit.tar.gz"), "kit_bytes": (destination / "kit.tar.gz").stat().st_size, "image": image, "image_id": image_id, "private_identity_included": False, "provider_upload_verified": False, "protected_version": transfer["version_id"]} checkpoint(destination / "kit-receipt.json", receipt) return receipt def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--execute", action="store_true") parser.add_argument("--evidence", type=Path, required=True) parser.add_argument("--ciphertext", type=Path, required=True) parser.add_argument("--destination", type=Path, required=True) args = parser.parse_args() if not args.execute: print(json.dumps({"status": "prepared", "image_exported": False, "provider_contacted": False})) return os.umask(0o077) print(json.dumps(prepare(args.evidence, args.ciphertext, args.destination))) if __name__ == "__main__": try: main() except Exception as error: print(json.dumps({"status": "failed", "failure_type": type(error).__name__})) raise SystemExit(1)