email-connect/scripts/publish-ledger-recovery-kit.py
tegwick 9a2cf213d1
Some checks failed
Transactional mail acceptance / mail (push) Failing after 32s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Prepare immutable caller rollout and independent Scaleway recovery custody
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a11fd9-caf6-7a63-b578-9daaaa134ff2
2026-10-09 19:49:24 +02:00

135 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Attended immutable kit upload and read-only recovery-caller readback."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
from ledger_archive import Refused, checkpoint, digest, private_directory, private_file
BUCKET = "railiance-email-connect-recovery"
ENDPOINT = "https://s3.nl-ams.scw.cloud"
def client(credentials):
import boto3
from botocore.config import Config
return boto3.client("s3", endpoint_url=ENDPOINT, region_name="nl-ams",
aws_access_key_id=credentials["AWS_ACCESS_KEY_ID"],
aws_secret_access_key=credentials["AWS_SECRET_ACCESS_KEY"],
config=Config(signature_version="s3v4", connect_timeout=15, read_timeout=180,
s3={"addressing_style": "path"}))
def credentials(path):
private_file(path)
value = json.loads(path.read_text())
if (set(value) != {"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}
or any(not isinstance(v, str) or not v.strip() for v in value.values())):
raise Refused("exact_private_s3_pair_required")
return value
def version(value):
if not isinstance(value, str) or not value or value == "null" or any(c in value for c in "\r\n"):
raise Refused("versioned_kit_required")
return value
def publish(owner, reader, kit, expected, output):
if (expected.get("status") != "recovery_kit_prepared"
or expected.get("private_identity_included") is not False
or digest(kit) != expected.get("kit_sha256")
or kit.stat().st_size != expected.get("kit_bytes")
or not 0 < kit.stat().st_size <= 1024**3):
raise Refused("verified_bounded_kit_required")
if owner.get_bucket_versioning(Bucket=BUCKET).get("Status") != "Enabled":
raise Refused("versioned_recovery_bucket_required")
try:
lifecycle = owner.get_bucket_lifecycle_configuration(Bucket=BUCKET)
except Exception as error:
if getattr(error, "response", {}).get("Error", {}).get("Code") != "NoSuchLifecycleConfiguration":
raise
else:
if lifecycle.get("Rules"):
raise Refused("nonexpiring_recovery_bucket_required")
key = "kits/" + expected["kit_sha256"] + ".tar.gz"
private_directory(output.parent)
if output.exists() or output.is_symlink():
raise Refused("fresh_kit_readback_required")
# Conditional creation preserves an existing immutable kit. Readback must
# use a separately admitted recovery identity, never the uploader.
try:
with kit.open("rb") as source:
created = owner.put_object(Bucket=BUCKET, Key=key, Body=source, IfNoneMatch="*",
Metadata={"sha256": expected["kit_sha256"]})
selected = version(created.get("VersionId"))
except Exception as error:
if getattr(error, "response", {}).get("ResponseMetadata", {}).get("HTTPStatusCode") != 412:
raise
head = owner.head_object(Bucket=BUCKET, Key=key)
if (head.get("Metadata", {}).get("sha256") != expected["kit_sha256"]
or head.get("ContentLength") != expected["kit_bytes"]):
raise Refused("existing_kit_binding_changed")
selected = version(head.get("VersionId"))
response = reader.get_object(Bucket=BUCKET, Key=key, VersionId=selected)
if response.get("VersionId") != selected or response.get("ContentLength") != expected["kit_bytes"]:
response["Body"].close()
raise Refused("kit_readback_version_changed")
hashed, size = hashlib.sha256(), 0
with response["Body"] as body, output.open("xb") as target:
output.chmod(0o600)
while block := body.read(8 * 1024**2):
size += len(block)
if size > expected["kit_bytes"]:
raise Refused("kit_readback_exceeds_expected_size")
hashed.update(block)
target.write(block)
if size != expected["kit_bytes"] or hashed.hexdigest() != expected["kit_sha256"]:
raise Refused("kit_readback_hash_changed")
return {"status": "scaleway_recovery_kit_readback_verified", "bucket": BUCKET,
"object_key": key, "version_id": selected, "kit_sha256": hashed.hexdigest(), "kit_bytes": size,
"separate_reader_readback_verified": True, "private_identity_included": False,
"reader_write_denial_verified": False, "worker_access_denial_verified": False,
"reader_application_binding_verified": False, "restored_from_provider_kit": False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--execute", action="store_true")
for name in ("kit", "kit-receipt", "owner-credentials", "reader-credentials", "output", "receipt"):
parser.add_argument("--" + name, type=Path)
args = parser.parse_args()
if not args.execute:
print(json.dumps({"status": "prepared", "bucket": BUCKET, "provider_contacted": False,
"caller_credentials_read": False, "private_identity_included": False}))
return
if not all((args.kit, args.kit_receipt, args.owner_credentials, args.reader_credentials, args.output, args.receipt)):
raise Refused("attended_kit_arguments_required")
private_directory(args.receipt.parent)
if args.receipt.exists() or args.receipt.is_symlink():
raise Refused("fresh_receipt_required")
private_file(args.kit)
private_file(args.kit_receipt)
owner, reader = credentials(args.owner_credentials), credentials(args.reader_credentials)
if owner["AWS_ACCESS_KEY_ID"] == reader["AWS_ACCESS_KEY_ID"]:
raise Refused("separate_recovery_identity_required")
os.umask(0o077)
try:
result = publish(client(owner), client(reader), args.kit,
json.loads(args.kit_receipt.read_text()), args.output)
except Exception as error:
checkpoint(args.receipt, {"status": "failed", "failure_type": type(error).__name__})
raise
checkpoint(args.receipt, result)
if __name__ == "__main__":
try:
main()
except Exception:
raise SystemExit(1) from None