#!/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