Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
139 lines
7.2 KiB
Python
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)
|