net-kingdom/tools/bootstrap-recovery/verify-bundle.py
tegwick d9557ba90e
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Harden remaining bootstrap custody callers and prepare isolated recovery
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-15 22:57:14 +02:00

139 lines
7.2 KiB
Python

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