email-connect/scripts/mirror-ledger.py
tegwick f77e2333af
Some checks failed
Transactional mail acceptance / mail (push) Failing after 46s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Retain independent ledger recovery custody and activate verified backups
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a11fd9-caf6-7a63-b578-9daaaa134ff2
2026-10-10 13:39:49 +02:00

89 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""Copy ciphertext into the isolated recovery project; never read an age key."""
import argparse
import hashlib
import json
import os
from pathlib import Path
from ledger_archive import Refused, checkpoint, digest, private_file
PROJECT = "1d37ea63-8c8f-44e1-80e1-2de862579ba7"
BUCKET = "railiance-email-connect-archives"
def mirror(client, archive, receipt):
private_file(archive)
expected = digest(archive)
size = archive.stat().st_size
if expected != receipt["ciphertext_sha256"] or size != receipt["ciphertext_bytes"]:
raise Refused("mirror_source_differs")
key = "archives/" + receipt["object_key"]
try:
with archive.open("rb") as source:
created = client.put_object(Bucket=BUCKET, Key=key, Body=source,
IfNoneMatch="*", Metadata={"sha256": expected})
version = created.get("VersionId")
except Exception as error:
if getattr(error, "response", {}).get("ResponseMetadata", {}).get("HTTPStatusCode") != 412:
raise
current = client.head_object(Bucket=BUCKET, Key=key)
if current.get("Metadata", {}).get("sha256") != expected or current.get("ContentLength") != size:
raise Refused("mirror_existing_object_differs")
version = current.get("VersionId")
if not version or version == "null":
raise Refused("versioned_mirror_required")
result = client.get_object(Bucket=BUCKET, Key=key, VersionId=version)
hashed, count = hashlib.sha256(), 0
with result["Body"] as stream:
while block := stream.read(1024**2):
count += len(block)
if count > size:
raise Refused("mirror_readback_exceeds_size")
hashed.update(block)
if (result.get("VersionId") != version or count != size
or hashed.hexdigest() != expected):
raise Refused("mirror_readback_differs")
# Recovery needs a receipt even if both node and workstation are lost.
record = dict(receipt, recovery_mirror={"bucket": BUCKET, "object_key": key,
"version_id": version, "project_id": PROJECT,
"hash_readback_verified": True})
payload = json.dumps(record, sort_keys=True).encode()
receipt_key = key + "." + version + "." + hashlib.sha256(payload).hexdigest() + ".receipt.json"
try:
saved = client.put_object(Bucket=BUCKET, Key=receipt_key, Body=payload, IfNoneMatch="*")
receipt_version = saved.get("VersionId")
except Exception as error:
if getattr(error, "response", {}).get("ResponseMetadata", {}).get("HTTPStatusCode") != 412:
raise
receipt_version = client.head_object(Bucket=BUCKET, Key=receipt_key).get("VersionId")
if not receipt_version or receipt_version == "null":
raise Refused("versioned_mirror_receipt_required")
saved = client.get_object(Bucket=BUCKET, Key=receipt_key, VersionId=receipt_version)
with saved["Body"] as stream:
if stream.read(len(payload) + 1) != payload:
raise Refused("mirror_receipt_readback_differs")
return dict(record["recovery_mirror"], receipt_key=receipt_key,
receipt_version_id=receipt_version)
def main():
import boto3
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--archive", type=Path, required=True)
parser.add_argument("--primary-receipt", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
private_file(args.primary_receipt)
key = os.environ["AWS_ACCESS_KEY_ID"].split("@", 1)[0] + "@" + PROJECT
client = boto3.client("s3", endpoint_url="https://s3.nl-ams.scw.cloud", region_name="nl-ams",
aws_access_key_id=key, aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"])
checkpoint(args.output, mirror(client, args.archive, json.loads(args.primary_receipt.read_text())))
if __name__ == "__main__":
try:
main()
except Exception as error:
print(json.dumps({"status": "failed", "failure_type": type(error).__name__}))
raise SystemExit(1) from None