Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a11fd9-caf6-7a63-b578-9daaaa134ff2
135 lines
6.1 KiB
Python
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
|