2026-10-08 00:06:59 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Private transactional-ledger archive primitives; no mail/provider code."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sqlite3
|
|
|
|
|
import stat
|
|
|
|
|
import subprocess
|
|
|
|
|
import tarfile
|
|
|
|
|
import tempfile
|
|
|
|
|
from contextlib import closing
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
MAX_BYTES = 128 * 1024**2
|
|
|
|
|
TABLES = {"deliveries", "verifications", "suppressions", "send_attempts", "verification_attempts"}
|
|
|
|
|
PREFIX = "s3://railiance-platform-pg-backup/platform-pg/application-archives/email-connect/"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Refused(RuntimeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def digest(path):
|
|
|
|
|
with path.open("rb") as stream:
|
|
|
|
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def private_directory(path):
|
|
|
|
|
info = path.lstat()
|
|
|
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
|
|
|
|
raise Refused("private_owner_directory_required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 write_private(path, value):
|
|
|
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
|
|
|
with os.fdopen(fd, "wb") as stream:
|
|
|
|
|
stream.write(value)
|
|
|
|
|
stream.flush()
|
|
|
|
|
os.fsync(stream.fileno())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def checkpoint(path, value):
|
|
|
|
|
fd, name = tempfile.mkstemp(prefix="receipt-", dir=path.parent)
|
|
|
|
|
with os.fdopen(fd, "w") as stream:
|
|
|
|
|
json.dump(value, stream, indent=2)
|
|
|
|
|
stream.flush()
|
|
|
|
|
os.fsync(stream.fileno())
|
|
|
|
|
Path(name).replace(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fingerprint(path):
|
|
|
|
|
private_file(path)
|
|
|
|
|
if not 0 < path.stat().st_size <= MAX_BYTES:
|
|
|
|
|
raise Refused("ledger_size_outside_contract")
|
|
|
|
|
with closing(sqlite3.connect(path.absolute().as_uri() + "?mode=ro", uri=True)) as database:
|
|
|
|
|
database.execute("PRAGMA trusted_schema=OFF")
|
|
|
|
|
if database.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
|
|
|
|
|
raise Refused("ledger_integrity_failed")
|
|
|
|
|
tables = {
|
|
|
|
|
row[0]
|
|
|
|
|
for row in database.execute(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-10-08 12:17:14 +02:00
|
|
|
if tables not in (TABLES, TABLES | {"caller_buckets"}):
|
2026-10-08 00:06:59 +02:00
|
|
|
raise Refused("unexpected_ledger_schema")
|
|
|
|
|
schema = list(
|
|
|
|
|
database.execute("SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name")
|
|
|
|
|
)
|
|
|
|
|
result = {
|
|
|
|
|
"schema_sha256": hashlib.sha256(json.dumps(schema).encode()).hexdigest(),
|
|
|
|
|
"user_version": database.execute("PRAGMA user_version").fetchone()[0],
|
|
|
|
|
"tables": {},
|
|
|
|
|
}
|
2026-10-08 12:17:14 +02:00
|
|
|
for name in sorted(tables):
|
2026-10-08 00:06:59 +02:00
|
|
|
rows = [
|
|
|
|
|
json.dumps(
|
|
|
|
|
row, separators=(",", ":"), default=lambda value: {"bytes_hex": value.hex()}
|
|
|
|
|
).encode()
|
|
|
|
|
for row in database.execute('SELECT * FROM "' + name + '"')
|
|
|
|
|
]
|
|
|
|
|
result["tables"][name] = {
|
|
|
|
|
"rows": len(rows),
|
|
|
|
|
"sha256": hashlib.sha256(b"\n".join(sorted(rows))).hexdigest(),
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sqlite_backup(source, output):
|
|
|
|
|
"""Use SQLite backup rather than copying a file that may still have WAL."""
|
|
|
|
|
private_directory(output.parent)
|
|
|
|
|
if source.is_symlink() or not source.is_file() or output.exists() or output.is_symlink():
|
|
|
|
|
raise Refused("invalid_sqlite_backup_paths")
|
|
|
|
|
# No source write or checkpoint; a read-only connection includes committed WAL.
|
|
|
|
|
with closing(sqlite3.connect(source.absolute().as_uri() + "?mode=ro", uri=True)) as src:
|
|
|
|
|
if (
|
|
|
|
|
src.execute("PRAGMA page_count").fetchone()[0]
|
|
|
|
|
* src.execute("PRAGMA page_size").fetchone()[0]
|
|
|
|
|
> MAX_BYTES
|
|
|
|
|
):
|
|
|
|
|
raise Refused("ledger_size_outside_contract")
|
|
|
|
|
write_private(output, b"")
|
|
|
|
|
with closing(sqlite3.connect(output)) as dst:
|
|
|
|
|
src.backup(dst, pages=256)
|
|
|
|
|
return fingerprint(output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate_capture(directory):
|
|
|
|
|
private_directory(directory)
|
|
|
|
|
private_file(directory / "receipt.json")
|
|
|
|
|
receipt = json.loads((directory / "receipt.json").read_text())
|
|
|
|
|
if (
|
|
|
|
|
receipt.get("status") != "captured"
|
|
|
|
|
or receipt.get("application") != "email-connect"
|
|
|
|
|
or receipt.get("writers_quiesced") is not True
|
|
|
|
|
or receipt.get("receiver_resumed") is not True
|
|
|
|
|
or receipt.get("source_pvc_read_only") is not True
|
|
|
|
|
):
|
|
|
|
|
raise Refused("capture_incomplete")
|
|
|
|
|
path = directory / "database.sqlite3"
|
|
|
|
|
actual = fingerprint(path)
|
|
|
|
|
if (
|
|
|
|
|
digest(path) != receipt.get("database_sha256")
|
|
|
|
|
or path.stat().st_size != receipt.get("database_bytes")
|
|
|
|
|
or actual != receipt.get("ledger_fingerprint")
|
|
|
|
|
):
|
|
|
|
|
raise Refused("capture_binding_mismatch")
|
|
|
|
|
return receipt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def command(argv, timeout=900):
|
|
|
|
|
# Keep private child diagnostics out of both memory pipes and the journal.
|
|
|
|
|
with tempfile.TemporaryFile() as output:
|
|
|
|
|
child = subprocess.Popen(argv, stdout=output, stderr=output)
|
|
|
|
|
try:
|
|
|
|
|
result = child.wait(timeout=timeout)
|
|
|
|
|
except BaseException:
|
|
|
|
|
child.terminate()
|
|
|
|
|
try:
|
|
|
|
|
child.wait(timeout=800)
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
child.kill()
|
|
|
|
|
child.wait()
|
|
|
|
|
raise
|
|
|
|
|
if result:
|
|
|
|
|
raise Refused("archive_command_failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def seal(directory, stage, recipient):
|
|
|
|
|
captured = validate_capture(directory)
|
|
|
|
|
encrypted, sealed_path = stage / "packet.tar.age", stage / "sealed.json"
|
|
|
|
|
binding = {
|
|
|
|
|
key: captured[key]
|
|
|
|
|
for key in ["database_sha256", "captured_at", "image", "ledger_fingerprint"]
|
|
|
|
|
}
|
|
|
|
|
if sealed_path.exists():
|
|
|
|
|
private_file(sealed_path)
|
|
|
|
|
private_file(encrypted)
|
|
|
|
|
receipt = json.loads(sealed_path.read_text())
|
|
|
|
|
if receipt.get("binding") != binding or receipt.get("ciphertext_sha256") != digest(
|
|
|
|
|
encrypted
|
|
|
|
|
):
|
|
|
|
|
raise Refused("sealed_packet_binding_mismatch")
|
|
|
|
|
return encrypted, captured
|
|
|
|
|
if encrypted.exists() or encrypted.is_symlink():
|
|
|
|
|
raise Refused("partial_encryption_requires_review")
|
|
|
|
|
archive = stage / "packet.tar"
|
|
|
|
|
with archive.open("xb") as stream:
|
|
|
|
|
os.chmod(archive, 0o600)
|
|
|
|
|
with tarfile.open(fileobj=stream, mode="w") as packet:
|
|
|
|
|
for name in ["database.sqlite3", "receipt.json"]:
|
|
|
|
|
packet.add(directory / name, arcname=name, recursive=False)
|
|
|
|
|
command(["age", "-r", recipient, "-o", str(encrypted), str(archive)])
|
|
|
|
|
encrypted.chmod(0o600)
|
|
|
|
|
private_file(encrypted)
|
|
|
|
|
if not 0 < encrypted.stat().st_size <= MAX_BYTES + 1024**2:
|
|
|
|
|
raise Refused("encrypted_packet_size_outside_contract")
|
|
|
|
|
checkpoint(sealed_path, {"binding": binding, "ciphertext_sha256": digest(encrypted)})
|
|
|
|
|
return encrypted, captured
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unpack(packet, destination):
|
|
|
|
|
if packet.stat().st_size > MAX_BYTES + 1024**2:
|
|
|
|
|
raise Refused("packet_too_large")
|
|
|
|
|
with tarfile.open(packet, "r:") as archive:
|
|
|
|
|
entries = archive.getmembers()
|
|
|
|
|
if (
|
|
|
|
|
len(entries) != 2
|
|
|
|
|
or {entry.name for entry in entries} != {"database.sqlite3", "receipt.json"}
|
|
|
|
|
or any(
|
|
|
|
|
not entry.isfile() or entry.sparse is not None or entry.size <= 0
|
|
|
|
|
for entry in entries
|
|
|
|
|
)
|
|
|
|
|
or sum(entry.size for entry in entries) > MAX_BYTES + 1024**2
|
|
|
|
|
or any(entry.size > 1024**2 for entry in entries if entry.name == "receipt.json")
|
|
|
|
|
):
|
|
|
|
|
raise Refused("invalid_packet_members")
|
|
|
|
|
for entry in entries:
|
|
|
|
|
with (
|
|
|
|
|
archive.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)
|
|
|
|
|
return validate_capture(destination)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def recover(encrypted, transfer_path, identity, destination):
|
|
|
|
|
for path in [encrypted, transfer_path, identity]:
|
|
|
|
|
private_file(path)
|
|
|
|
|
transfer = json.loads(transfer_path.read_text())
|
|
|
|
|
key = transfer.get("object_key", "")
|
|
|
|
|
suffix = key.removeprefix("platform-pg/application-archives/email-connect/")
|
|
|
|
|
version = transfer.get("version_id")
|
|
|
|
|
if (
|
|
|
|
|
not isinstance(version, str)
|
|
|
|
|
or version in {"", "null"}
|
|
|
|
|
or any(char in version for char in "\r\n")
|
|
|
|
|
or not key.startswith("platform-pg/application-archives/email-connect/")
|
|
|
|
|
or not re.fullmatch(r"\d{4}/\d{2}/\d{2}/\d{8}T\d{6}Z-[a-f0-9]{24}\.tar\.age", suffix)
|
|
|
|
|
or transfer.get("destination") != "s3://railiance-platform-pg-backup/" + key
|
|
|
|
|
):
|
|
|
|
|
raise Refused("verified_transfer_required")
|
|
|
|
|
if (
|
|
|
|
|
transfer.get("schema") != "platform.application-primary-archive.v1"
|
|
|
|
|
or transfer.get("application") != "email-connect"
|
|
|
|
|
or transfer.get("status") != "offsite_fetched_verified"
|
|
|
|
|
or transfer.get("remote_retrieval_verified") is not True
|
|
|
|
|
or not transfer.get("version_id")
|
|
|
|
|
or transfer["version_id"] == "null"
|
|
|
|
|
or not transfer.get("destination", "").startswith(PREFIX)
|
|
|
|
|
or transfer.get("ciphertext_sha256") != digest(encrypted)
|
|
|
|
|
or transfer.get("ciphertext_bytes") != encrypted.stat().st_size
|
|
|
|
|
):
|
|
|
|
|
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"
|
|
|
|
|
# age must create plaintext with private modes even in an interactive shell.
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
["age", "-d", "-i", str(identity), "-o", str(packet), str(encrypted)],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
timeout=900,
|
|
|
|
|
umask=0o077,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode:
|
|
|
|
|
raise Refused("decryption_failed")
|
|
|
|
|
private_file(packet)
|
|
|
|
|
captured = unpack(packet, destination)
|
|
|
|
|
for key in ["captured_at", "image", "database_sha256", "ledger_fingerprint"]:
|
|
|
|
|
if transfer.get(key) != captured.get(key):
|
|
|
|
|
raise Refused("recovery_capture_binding_mismatch")
|
|
|
|
|
packet.unlink()
|
|
|
|
|
result = {
|
|
|
|
|
"status": "decrypted_ledger_verified",
|
|
|
|
|
"application": "email-connect",
|
|
|
|
|
"object_key": transfer["object_key"],
|
|
|
|
|
"version_id": transfer["version_id"],
|
|
|
|
|
"ciphertext_sha256": transfer["ciphertext_sha256"],
|
|
|
|
|
"database_sha256": captured["database_sha256"],
|
|
|
|
|
"ledger_fingerprint": captured["ledger_fingerprint"],
|
|
|
|
|
"mail_sent": False,
|
|
|
|
|
"production_restored": False,
|
|
|
|
|
"application_restore_verified": False,
|
|
|
|
|
}
|
|
|
|
|
checkpoint(destination / "unpack-receipt.json", result)
|
|
|
|
|
return result
|