Prove isolated custody recovery and prepare governed offsite finish
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
codex 2026-10-03 20:47:48 +02:00
parent dff2d6e9f7
commit a252447058
11 changed files with 528 additions and 1 deletions

View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec python3 "$(dirname "$0")/root_custody_finish.py" "$@" >/dev/null 2>&1

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Silent attended current-source and governed provider history recovery proof."""
import argparse
import configparser
import hmac
from urllib.parse import urlsplit,unquote
from datetime import datetime,timezone
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import uuid
from root_grant_attended import Bao, RECORD_ID, atomic_json, check_baseline, digest, require_attended
from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, LANE, quota, request
VALIDATOR_DIGEST='9156e399c8d92a45b817bfc3738c89843487ce57e1718ed747f804f95cc719bb'
SCHEMA_DIGEST='bb6cd6da5dc48eddcf118ae78c2cacfafd5f860098fd8a8368fe6bc845776d07'
def checked_bundle(state_path, validator):
state=json.loads(state_path.read_text())
if state.get('pending') or validator.validate(state['document']) or state['record_id']!=RECORD_ID or digest(state['document'])!=state['digest']:
raise ValueError('retained_state_invalid')
history=[]
for path in sorted((state_path.parent/'history').glob('*.json')):
if path.is_symlink() or path.stat().st_size>262144:
raise ValueError('history_invalid')
receipt=json.loads(path.read_text())
if validator.validate(receipt['document']) or receipt['record_id']!=RECORD_ID or receipt['digest']!=digest(receipt['document']):
raise ValueError('history_invalid')
history.append(receipt)
if state not in history:
raise ValueError('latest_history_missing')
return state,json.dumps({'baseline':state,'history':history},sort_keys=True,separators=(',',':')).encode()
def historical_config_comparison(config_path, values):
if not config_path.exists():
return {"historical_config_present":False,"historical_share_matches_current_lane":None}
try:
config=configparser.ConfigParser(interpolation=None)
config.read(config_path)
remote=config['nextcloud-backup']
candidates=[remote.get('user','')]+unquote(urlsplit(remote.get('url','')).path).split('/')
matched=any(hmac.compare_digest(value,values['NC_WEBDAV_TOKEN']) for value in candidates if value)
return {"historical_config_present":True,"historical_share_matches_current_lane":matched,
"historical_credential_status":"current-lane-incident" if matched else "historical-owner-and-revocation-unverified"}
except Exception:
return {"historical_config_present":True,"historical_share_matches_current_lane":None,
"historical_credential_status":"comparison-unavailable"}
def roundtrip(bundle, lane, operator, network=request, get_quota=quota):
values=lane['data']
if operator['BACKUP_USERNAME']!='Backup':
raise ValueError('governed_owner_mismatch')
auth=(operator['BACKUP_USERNAME'],operator['BACKUP_PASSWORD'])
upload=values['NC_WEBDAV_URL']
if not upload.startswith(HOST+'/public.php/dav/filesdrop/') or values['NC_WEBDAV_TOKEN']!=upload.rsplit('/',1)[-1]:
raise ValueError('current_lane_contract_mismatch')
key=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode()
def process(command,payload):
result=subprocess.run(command,input=payload,capture_output=True,timeout=30)
if result.returncode:
raise ValueError('protected_process_failed')
return result.stdout
recipient=process(['age-keygen','-y','/dev/stdin'],key).decode().strip()
encrypted=process(['age','-r',recipient],bundle)
if len(encrypted)>1048576 or get_quota(auth)['available_bytes']<len(encrypted):
raise ValueError('backup_size_or_quota_invalid')
name='root-custody-history-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'-'+uuid.uuid4().hex+'.json.age'
status,_=network(upload+'/forgejo/'+name,'PUT',encrypted,(values['NC_WEBDAV_TOKEN'],''),{'Content-Type':'application/octet-stream','If-None-Match':'*'})
if status!=201:
raise ValueError('new_artifact_upload_failed')
status,fetched=network(HOST+'/remote.php/dav/files/Backup/railiance-backups/forgejo/'+name,'GET',auth=auth)
if status!=200 or hashlib.sha256(fetched).digest()!=hashlib.sha256(encrypted).digest():
raise ValueError('provider_readback_mismatch')
with tempfile.TemporaryDirectory(prefix='root-custody-recovery-') as tmp:
cipher=Path(tmp)/'history.json.age'
cipher.write_bytes(fetched)
cipher.chmod(0o600)
restored=process(['age','-d','-i','/dev/stdin',str(cipher)],key)
if restored!=bundle:
raise ValueError('decrypted_history_mismatch')
return {'provider':'existing-governed-nextcloud-backup','artifact':name,'upload_http_status':201,'readback_http_status':200,'ciphertext_sha256':hashlib.sha256(encrypted).hexdigest(),'decrypted_history_matches':True,'lane_kv_version':lane['metadata']['version'],'local_historical_rclone_used':False}
def main():
p=argparse.ArgumentParser()
p.add_argument('--state',type=Path,required=True)
p.add_argument('--net-kingdom',type=Path,required=True)
p.add_argument('--receipt',type=Path,required=True)
args=p.parse_args()
try:
if args.receipt.exists() or args.receipt.is_symlink():
return 1
require_attended()
if os.getenv('BAO_ADDR')!='http://127.0.0.1:18200':
return 1
source=args.net_kingdom/'tools/custody-record/validate.py'
schema=args.net_kingdom/'canon/schemas/custody-record_v0.1.schema.json'
if hashlib.sha256(source.read_bytes()).hexdigest()!=VALIDATOR_DIGEST or hashlib.sha256(schema.read_bytes()).hexdigest()!=SCHEMA_DIGEST:
return 1
spec=importlib.util.spec_from_file_location('custody_validator',source)
validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator)
import fcntl
with (args.state.parent/'publisher.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX)
state,bundle=checked_bundle(args.state,validator)
bao=Bao()
check_baseline(bao.read('platform/data/custody/net-kingdom/platform-root'),state,validator)
# Only current owner-held lane; no cached rclone config or caller token.
lane=bao.read(LANE)
operator=bao.read(OPERATOR_PATH)['data']
comparison=historical_config_comparison(Path('/home/worsch/.config/rclone/rclone.conf'),lane['data'])
if comparison['historical_share_matches_current_lane'] is True:
atomic_json(args.receipt,{'ok':False,'current_live_baseline_matches':True,**comparison,
'error':'current_backup_lane_requires_coordinated_existing_incident_recovery','recorded_at':datetime.now(timezone.utc).isoformat()})
return 1
receipt={**roundtrip(bundle,lane,operator),**comparison}
atomic_json(args.receipt,{'ok':True,'scope':'current-owner-read-and-governed-history-provider-roundtrip','record_id':RECORD_ID,'revision':state['revision'],'kv_version':state['kv_version'],'digest':state['digest'],'current_live_baseline_matches':True,'history_entries':len(json.loads(bundle)['history']),'recorded_at':datetime.now(timezone.utc).isoformat(),**receipt})
return 0
except Exception:
# Server/credential/HTTP/process diagnostics are never disclosed.
return 1
if __name__=='__main__':
raise SystemExit(main())

View file

@ -0,0 +1,156 @@
#!/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)