#!/usr/bin/env python3 """Disposable loopback Raft recovery and workstation receipt-copy proof. No live admin login; no production backup is read; no key/token is emitted. Temporary local-server credentials are generated and used only inside this run. """ import argparse import hashlib import importlib.util import json import os from pathlib import Path import shutil import socket import subprocess import sys import tempfile import time sys.path.insert(0, str(Path(__file__).resolve().parent)) import root_grant_attended as owner def run(args, environ, optional=False): result = subprocess.run(["bao", *args], env=environ, capture_output=True, timeout=20) if result.returncode and not optional: raise RuntimeError("isolated_command_failed") return result def main(): parser = argparse.ArgumentParser() parser.add_argument('--state', type=Path, required=True) parser.add_argument('--net-kingdom', type=Path, required=True) parser.add_argument('--receipt', type=Path, required=True) args = parser.parse_args() if args.receipt.exists(): raise RuntimeError('unique_receipt_required') source = args.net_kingdom / 'tools/custody-record/validate.py' if hashlib.sha256(source.read_bytes()).hexdigest() != '9156e399c8d92a45b817bfc3738c89843487ce57e1718ed747f804f95cc719bb': raise RuntimeError('reviewed_validator_required') spec = importlib.util.spec_from_file_location('custody_validator', source) validator = importlib.util.module_from_spec(spec) spec.loader.exec_module(validator) retained = json.loads(args.state.read_text()) if retained.get('pending') or validator.validate(retained['document']) or retained['document']['entries']: raise RuntimeError('reviewed_empty_baseline_required') state_digest = owner.digest(retained['document']) if state_digest != retained['digest'] or retained['kv_version'] != 1: raise RuntimeError('baseline_digest_or_version_mismatch') with tempfile.TemporaryDirectory(prefix='nk-root-raft-proof-') as temporary: scratch = Path(temporary) copied = scratch / 'receipt-holder-copy' copied.mkdir(mode=0o700) shutil.copy2(args.state, copied/'baseline.json') shutil.copytree(args.state.parent/'history', copied/'history') restored = scratch/'receipt-holder-restored' shutil.copytree(copied, restored) actual_state = json.loads((restored/'baseline.json').read_text()) history_file = restored/'history'/('1-'+state_digest+'.json') if actual_state != retained or json.loads(history_file.read_text()) != retained: raise RuntimeError('receipt_holder_restore_differs') if ((restored/'baseline.json').stat().st_mode & 0o777) != 0o600: raise RuntimeError('receipt_holder_permissions_differ') raft = scratch/'raft' raft.mkdir(mode=0o700) sock = socket.socket() sock.bind(('127.0.0.1',0)) port = sock.getsockname()[1] sock.close() if port in (8200,18200): raise RuntimeError('reserved_live_port_refused') config = scratch/'server.hcl' config.write_text('disable_mlock=true\nstorage "raft" {\n path="'+str(raft)+'"\n node_id="isolated-root-recovery"\n}\nlistener "tcp" {\n address="127.0.0.1:'+str(port)+'"\n tls_disable=true\n}\napi_addr="http://127.0.0.1:'+str(port)+'"\ncluster_addr="http://127.0.0.1:'+str(port+1)+'"\n') env = {key:value for key,value in os.environ.items() if key not in ('BAO_TOKEN','VAULT_TOKEN','BAO_ADDR','VAULT_ADDR','VAULT_NAMESPACE','BAO_NAMESPACE')} env.update(BAO_ADDR='http://127.0.0.1:'+str(port), VAULT_ADDR='http://127.0.0.1:'+str(port), GOMAXPROCS='2') server = subprocess.Popen(['bao','server','-config='+str(config)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) try: for attempt in range(60): status = run(['status','-format=json'],env,optional=True) if status.stdout: break if server.poll() is not None: raise RuntimeError('isolated_server_exited') time.sleep(.1) initialized = json.loads(run(['operator','init','-format=json','-key-shares=1','-key-threshold=1'],env).stdout) seal = initialized['unseal_keys_b64'][0] env['BAO_TOKEN'] = initialized['root_token'] # Key is passed only through captured child argv to an isolated process; # never logged, persisted in source or emitted in evidence. run(['operator','unseal',seal],env) run(['secrets','enable','-path=platform','kv-v2'],env) older = scratch/'before-custody.snap' latest = scratch/'with-custody.snap' run(['operator','raft','snapshot','save',str(older)],env) # Exact owner library, on isolated transport only; env is confined # to a short-lived child script invocation below for subprocess Bao. class IsolatedBao(owner.Bao): def call(self, command, payload=None, optional=False): result = subprocess.run(['bao',*command],input=None if payload is None else json.dumps(payload).encode(),env=env,capture_output=True,timeout=15) if result.returncode: if optional and result.returncode==2 and result.stderr.strip()==('No value found at '+command[-1]).encode(): return None raise owner.Refused('isolated_request_failed') return json.loads(result.stdout) if result.stdout.strip() else {} bao = IsolatedBao() synthetic_state = scratch/'publisher.json' owner.publish(bao,retained['document'],synthetic_state,validator,'isolated-copy-of-approved-empty-baseline') owner.check_baseline(bao.read(owner.PATH),actual_state,validator) run(['operator','raft','snapshot','save',str(latest)],env) run(['operator','raft','snapshot','restore','-force',str(older)],env) for attempt in range(60): try: old_read = bao.read(owner.PATH,optional=True) break except owner.Refused: run(['operator','unseal',seal],env,optional=True) time.sleep(.1) else: raise RuntimeError('isolated_old_snapshot_unreadable') refused = False try: owner.check_baseline(old_read,actual_state,validator) except owner.Refused: refused = True if not refused: raise RuntimeError('old_snapshot_not_refused') run(['operator','raft','snapshot','restore','-force',str(latest)],env) for attempt in range(60): try: latest_read = bao.read(owner.PATH) owner.check_baseline(latest_read,actual_state,validator) break except (owner.Refused,KeyError,TypeError): run(['operator','unseal',seal],env,optional=True) time.sleep(.1) else: raise RuntimeError('latest_snapshot_did_not_match') evidence={'ok':True,'scope':'disposable-loopback-openbao-raft','production_mutated':False,'production_snapshot_used':False,'receipt_holder_scratch_restore_verified':True,'receipt_holder_backup_provider_verified':False,'old_snapshot_missing_source_refused':True,'latest_snapshot_matches_independent_baseline':True,'record_id':owner.RECORD_ID,'revision':1,'kv_version':1,'digest':state_digest,'entries_count':0,'server_version':run(['version'],env).stdout.decode().strip(),'snapshot_count':2,'temporary_artifacts_removed':True} finally: server.terminate() try: server.wait(timeout=10) except subprocess.TimeoutExpired: server.kill() server.wait() owner.atomic_json(args.receipt,evidence) print(json.dumps(evidence,sort_keys=True)) if __name__=='__main__': try: main() except Exception: print('{"ok":false,"error":"isolated_recovery_proof_failed"}') sys.exit(1)