Prove isolated custody recovery and prepare governed offsite finish
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
dff2d6e9f7
commit
a252447058
11 changed files with 528 additions and 1 deletions
3
scripts/root-custody-finish-attended.sh
Executable file
3
scripts/root-custody-finish-attended.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname "$0")/root_custody_finish.py" "$@" >/dev/null 2>&1
|
||||
133
scripts/root_custody_finish.py
Normal file
133
scripts/root_custody_finish.py
Normal 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())
|
||||
156
scripts/root_custody_restore_probe.py
Normal file
156
scripts/root_custody_restore_probe.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue