#!/usr/bin/env python3 """Isolated ciphertext recovery check; no cluster, credentials or values emitted. This verifies the handoff transport and exact requested file coverage. Current service validity, independent source custody and native consumer recovery need separate owner evidence; this tool cannot infer them from a decrypted archive. """ import argparse import hashlib import importlib.util import io import json import os from pathlib import Path, PurePosixPath import re import signal import subprocess import sys import tarfile import tempfile from datetime import datetime, timezone ROOT = Path(__file__).resolve().parents[2] spec = importlib.util.spec_from_file_location('private_age_recovery', ROOT / 'sso-mfa/bootstrap/sops-private.py') custody = importlib.util.module_from_spec(spec); spec.loader.exec_module(custody) LIMIT = 64 * 1024 * 1024 ALLOWED = {f'{c}/secrets.env' for c in ('privacyidea', 'postgres', 'lldap', 'authelia', 'keycape', 'breakglass')} | { 'privacyidea/pi.enc', 'privacyidea/private.pem', 'privacyidea/public.pem', 'keycape/key.pem', 'authelia/oidc_private_key.pem', 'openbao/init.json'} class Refused(Exception): pass def recover(ciphertext, keyfd, required, work): # Exclude ambient age keys/plugins and unrelated credential environments. env = {'PATH': os.environ.get('PATH', os.defpath), 'HOME': str(work), 'XDG_CONFIG_HOME': str(work)} result = subprocess.run(['age', '-d', '-i', f'/proc/self/fd/{keyfd}'], input=ciphertext, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, pass_fds=(keyfd,), timeout=60, env=env, cwd=work) if result.returncode or not result.stdout or len(result.stdout) > LIMIT: raise Refused() restored = set(); names = set(); total = 0 with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive: for index, member in enumerate(archive): if index >= 128 or member.name in names: raise Refused() names.add(member.name) path = PurePosixPath(member.name) if (path.is_absolute() or '..' in path.parts or '.' in path.parts or not path.parts or path.parts[0] != 'secrets'): raise Refused() if member.isdir(): if len(path.parts) > 2 or (len(path.parts) == 2 and path.parts[1] not in {p.split('/')[0] for p in ALLOWED}): raise Refused() continue relative = '/'.join(path.parts[1:]) if not member.isfile() or relative not in ALLOWED or not 0 <= member.size <= 16 * 1024 * 1024: raise Refused() total += member.size if total > LIMIT or member.size == 0: raise Refused() content = archive.extractfile(member).read() if len(content) != member.size or b'AGE-SECRET-KEY-' in content: raise Refused() target = work / 'restored' / relative target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, 'wb') as dest: dest.write(content); dest.flush(); os.fsync(dest.fileno()) if target.read_bytes() != content: raise Refused() restored.add(relative) if not required.issubset(restored): raise Refused() return sorted(restored) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--bundle', required=True) parser.add_argument('--bundle-sha256', required=True) parser.add_argument('--required-file', action='append', required=True) parser.add_argument('--receipt', required=True) args = parser.parse_args() if not re.fullmatch('[a-f0-9]{64}', args.bundle_sha256): raise Refused() required = set(args.required_file) if not required or required - ALLOWED or len(required) != len(args.required_file): raise Refused() path = Path(args.bundle).absolute() if any(p.is_symlink() for p in (path, *path.parents)): raise Refused() with path.open('rb') as file: data = file.read(LIMIT + 1) if len(data) > LIMIT or hashlib.sha256(data).hexdigest() != args.bundle_sha256: raise Refused() recipient = custody.public_recipient() keyfd = custody.private_key_fd() try: derived = subprocess.run(['age-keygen', '-y', f'/proc/self/fd/{keyfd}'], pass_fds=(keyfd,), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10) if derived.returncode or derived.stdout.strip() != recipient.encode(): raise Refused() # Check the negative path with a disposable unrelated key. It never # replaces the registered key, leaves temporary custody, or contacts IAM. with tempfile.TemporaryDirectory(prefix='netkingdom-recovery-', dir='/tmp') as temporary: work = Path(temporary) disposable = work / 'wrong.key' created = subprocess.run(['age-keygen', '-o', str(disposable)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) if created.returncode: raise Refused() wrong = subprocess.run(['age', '-d', '-i', str(disposable)], input=data, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60, env={'PATH': os.environ.get('PATH', os.defpath), 'HOME': temporary, 'XDG_CONFIG_HOME': temporary}, cwd=temporary) if wrong.returncode == 0: raise Refused() restored = recover(data, keyfd, required, work) if work.exists(): raise Refused() finally: os.close(keyfd) receipt = {'schema': 'netkingdom.bootstrap-recovery-transport.v1', 'observed_at': datetime.now(timezone.utc).isoformat(), 'bundle_sha256': args.bundle_sha256, 'registered_recipient_match': True, 'wrong_key_refused': True, 'required_files': sorted(required), 'restored_files': restored, 'isolated_restore_readback_passed': True, 'temporary_plaintext_cleanup_verified': True, 'secret_values_exported': False, 'cluster_contacted': False, 'independent_custody_proven': False, 'current_service_validity_proven': False, 'native_consumer_recovery_proven': False} # Publish only the closed, safe receipt shape; preserve existing receipts. fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) with os.fdopen(fd, 'w') as dest: json.dump(receipt, dest, indent=2); dest.write('\n') print('Ciphertext transport/isolated restore checks passed. Independent custody and current service recovery remain separate owner evidence.') if __name__ == '__main__': try: for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): signal.signal(sig, custody.interrupted) main() except (Refused, custody.Refused, OSError, ValueError, TypeError, KeyError, tarfile.TarError, subprocess.SubprocessError): print('ERROR: recovery transport check refused or failed; no acceptance receipt. Private diagnostics suppressed.', file=sys.stderr) raise SystemExit(1)