coulomb-social/scripts/recover-paired-backup.py
tegwick a2cb673e0a
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m5s
Prepare verified immutable paired backups and attended recovery
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a11538-05ea-7b63-b3c7-a2df5f38898d
2026-10-07 23:23:33 +02:00

160 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Attended private decryption/unpacking; never modifies a running application."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import stat
import subprocess
import tarfile
from pathlib import Path
MAX_PACKET_BYTES = 20 * 1024**3
EXPECTED = {"database.dump", "content.tar.gz", "receipt.json"}
PREFIX = "s3://railiance-platform-pg-backup/platform-pg/application-archives/coulomb-social/"
class Refused(RuntimeError):
pass
def digest(path):
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def private_file(path):
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise Refused("private_owner_file_required")
def unpack_packet(packet: Path, destination: Path) -> dict:
"""Inspect the complete tar before writing any member; ignore stored modes."""
if packet.stat().st_size > MAX_PACKET_BYTES:
raise Refused("packet_too_large")
with tarfile.open(packet, "r:") as bundle:
entries = bundle.getmembers()
if (
len(entries) != len(EXPECTED)
or {e.name for e in entries} != EXPECTED
or any(not e.isfile() or e.sparse is not None for e in entries)
or sum(e.size for e in entries) > MAX_PACKET_BYTES
or any(e.size <= 0 or (e.name == "receipt.json" and e.size > 1024**2) for e in entries)
):
raise Refused("invalid_packet_members")
for entry in entries:
with (
bundle.extractfile(entry) as source,
(destination / entry.name).open("xb") as target,
):
os.chmod(target.name, 0o600)
remaining = entry.size
while remaining:
block = source.read(min(remaining, 8 * 1024**2))
if not block:
raise Refused("truncated_packet_member")
target.write(block)
remaining -= len(block)
receipt = json.loads((destination / "receipt.json").read_text())
if (
receipt.get("status") != "captured"
or not receipt.get("writers_quiesced")
or not receipt.get("app_resumed")
or receipt.get("database") != "coulomb_social_db"
):
raise Refused("pair_incomplete")
for name, prefix in [("database.dump", "database"), ("content.tar.gz", "content")]:
path = destination / name
if (
path.stat().st_size != receipt[prefix + "_bytes"]
or digest(path) != receipt[prefix + "_sha256"]
):
raise Refused("pair_hash_or_size_mismatch")
return receipt
def recover(archive: Path, transfer_receipt: Path, identity: Path, destination: Path) -> dict:
for path in [archive, transfer_receipt, identity]:
private_file(path)
transfer = json.loads(transfer_receipt.read_text())
encrypted_hash = transfer.get("encrypted_sha256", transfer.get("ciphertext_sha256"))
encrypted_bytes = transfer.get("encrypted_bytes", transfer.get("ciphertext_bytes"))
version = transfer.get("version_id")
if (
transfer.get("status") != "offsite_fetched_verified"
or transfer.get("application") != "coulomb-social"
or not transfer.get("remote_retrieval_verified")
or not isinstance(version, str)
or version in {"", "null"}
or "\n" in version
or not transfer.get("destination", "").startswith(PREFIX)
or not re.fullmatch("[a-f0-9]{64}", encrypted_hash or "")
or archive.stat().st_size != encrypted_bytes
or digest(archive) != encrypted_hash
):
raise Refused("verified_transfer_required")
if not destination.is_absolute() or destination.exists() or destination.is_symlink():
raise Refused("fresh_absolute_destination_required")
destination.mkdir(mode=0o700)
packet = destination / "packet.tar"
try:
result = subprocess.run(
["age", "-d", "-i", str(identity), "-o", str(packet), str(archive)],
capture_output=True,
timeout=900,
umask=0o077,
)
except subprocess.TimeoutExpired:
raise Refused("decryption_timeout") from None
if result.returncode:
raise Refused("decryption_failed")
private_file(packet)
receipt = unpack_packet(packet, destination)
if receipt.get("started_at") != transfer.get("captured_at"):
raise Refused("capture_binding_mismatch")
# Root worker receipts also bind inner pair hashes and application image.
for key in ["image", "database_sha256", "content_sha256"]:
if key in transfer and transfer[key] != receipt.get(key):
raise Refused("application_binding_mismatch")
packet.unlink()
result = {
"status": "decrypted_pair_verified",
"version_id": version,
"encrypted_sha256": encrypted_hash,
"captured_at": receipt["started_at"],
"database_sha256": receipt["database_sha256"],
"content_sha256": receipt["content_sha256"],
"application_restore_verified": False,
}
with (destination / "unpack-receipt.json").open("x") as stream:
os.chmod(stream.name, 0o600)
json.dump(result, stream, indent=2)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--archive", type=Path, required=True)
parser.add_argument("--transfer-receipt", type=Path, required=True)
parser.add_argument("--identity", type=Path, required=True)
parser.add_argument("--destination", type=Path, required=True)
parser.add_argument("--execute", action="store_true")
args = parser.parse_args()
if not args.execute:
print(json.dumps({"status": "reviewed", "decryption_performed": False}))
return 0
print(json.dumps(recover(args.archive, args.transfer_receipt, args.identity, args.destination)))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as error:
print(json.dumps({"status": "failed", "failure_type": type(error).__name__}))
raise SystemExit(1)