157 lines
8.1 KiB
Python
157 lines
8.1 KiB
Python
|
|
#!/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)
|