railiance-platform/scripts/root_custody_restore_probe.py

157 lines
8.1 KiB
Python
Raw Permalink Normal View History

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