railiance-platform/scripts/root_custody_finish.py
codex 50da37cf50
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
fix(backup): send required public DAV upload marker
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
2026-10-03 21:20:21 +02:00

226 lines
12 KiB
Python

#!/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'
INDEPENDENT_ESCROW=Path('/home/worsch/.config/age/railiance-backup.key')
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 FinishFailure('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 FinishFailure('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 FinishFailure('history_invalid')
history.append(receipt)
if state not in history:
raise FinishFailure('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=None, get_quota=None, progress=lambda stage, **details: None):
network=request if network is None else network
get_quota=quota if get_quota is None else get_quota
progress('provider_contract')
values=lane['data']
if operator['BACKUP_USERNAME']!='Backup':
raise FinishFailure('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 FinishFailure('current_lane_contract_mismatch')
key=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode()
def process(command,payload):
try:
result=subprocess.run(command,input=payload,capture_output=True,timeout=30)
except FileNotFoundError:
raise FinishFailure('crypto_binary_unavailable') from None
except subprocess.TimeoutExpired:
raise FinishFailure('crypto_command_timeout') from None
if result.returncode:
raise FinishFailure('crypto_command_failed')
return result.stdout
progress('derive_age_recipient')
recipient=process(['age-keygen','-y','/dev/stdin'],key).decode().strip()
progress('encrypt_history')
encrypted=process(['age','-r',recipient],bundle)
progress('independent_key_escrow')
if not INDEPENDENT_ESCROW.is_file():
raise FinishFailure('independent_key_escrow_missing')
independent_recipient=process(['age-keygen','-y',str(INDEPENDENT_ESCROW)],b'').decode().strip()
matched=hmac.compare_digest(recipient,independent_recipient)
progress('independent_key_escrow',independent_escrow_recipient_matches_current_lane=matched)
if not matched:
raise FinishFailure('independent_key_escrow_mismatch')
progress('provider_quota')
if len(encrypted)>1048576 or get_quota(auth)['available_bytes']<len(encrypted):
raise FinishFailure('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'
progress('provider_upload',artifact=name)
status,_=network(upload+'/forgejo/'+name,'PUT',encrypted,(values['NC_WEBDAV_TOKEN'],''),{'Content-Type':'application/octet-stream','If-None-Match':'*','X-Requested-With':'XMLHttpRequest'})
progress('provider_upload',upload_http_status=status)
if status!=201:
raise FinishFailure('new_artifact_upload_failed')
progress('provider_download')
status,fetched=network(HOST+'/remote.php/dav/files/Backup/railiance-backups/forgejo/'+name,'GET',auth=auth)
progress('provider_download',readback_http_status=status)
if status!=200:
raise FinishFailure('provider_download_failed')
progress('provider_ciphertext_check')
if hashlib.sha256(fetched).digest()!=hashlib.sha256(encrypted).digest():
raise FinishFailure('provider_ciphertext_mismatch')
progress('decrypt_and_compare_history')
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',str(INDEPENDENT_ESCROW),str(cipher)],b'')
if restored!=bundle:
raise FinishFailure('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,'independent_escrow_recipient_matches_current_lane':True,'independent_escrow_decryption_verified':True}
STAGES = {
'startup', 'attended_envelope', 'operator_endpoint', 'canon_pins', 'validator_import',
'publisher_lock', 'retained_history', 'current_source_read', 'current_baseline_check',
'backup_lane_read', 'backup_operator_read', 'historical_share_comparison',
'provider_contract', 'derive_age_recipient', 'encrypt_history', 'independent_key_escrow', 'provider_quota',
'provider_upload', 'provider_download', 'provider_ciphertext_check', 'decrypt_and_compare_history', 'complete',
}
class FinishFailure(ValueError):
def __init__(self, code):
self.code = code
ERROR_CODES = {'stage_failed', 'attended_envelope_required', 'approved_operator_endpoint_required',
'canon_pin_mismatch', 'current_backup_lane_incident', 'unexpected_stage',
'crypto_binary_unavailable','crypto_command_timeout','crypto_command_failed','independent_key_escrow_missing','independent_key_escrow_mismatch',
'provider_download_failed','provider_ciphertext_mismatch','decrypted_history_mismatch',
'retained_state_invalid','history_invalid','latest_history_missing',
'governed_owner_mismatch','current_lane_contract_mismatch','backup_size_or_quota_invalid','new_artifact_upload_failed'}
def main(receipt_reserved=False):
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()
result={'ok':False,'stage':'startup','status':'started',
'recorded_at':datetime.now(timezone.utc).isoformat()}
def progress(stage, **details):
if stage not in STAGES:
raise FinishFailure('unexpected_stage')
result['stage']=stage
for key,value in details.items():
if key in {'upload_http_status','readback_http_status'} and type(value) is int and 100 <= value <= 599:
result[key]=value
elif key=='independent_escrow_recipient_matches_current_lane' and type(value) is bool:
result[key]=value
elif key=='artifact' and isinstance(value,str) and value.startswith('root-custody-history-') and value.endswith('.json.age'):
result[key]=value
atomic_json(args.receipt,result)
try:
if args.receipt.is_symlink():
return 1
if not receipt_reserved:
fd=os.open(args.receipt,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
os.close(fd)
progress('attended_envelope')
try:
require_attended()
except Exception:
raise FinishFailure('attended_envelope_required') from None
progress('operator_endpoint')
if os.getenv('BAO_ADDR')!='http://127.0.0.1:18200':
raise FinishFailure('approved_operator_endpoint_required')
progress('canon_pins')
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:
raise FinishFailure('canon_pin_mismatch')
progress('validator_import')
spec=importlib.util.spec_from_file_location('custody_validator',source)
validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator)
import fcntl
progress('publisher_lock')
with (args.state.parent/'publisher.lock').open('a') as lock:
fcntl.flock(lock,fcntl.LOCK_EX)
progress('retained_history')
state,bundle=checked_bundle(args.state,validator)
bao=Bao()
progress('current_source_read')
actual=bao.read('platform/data/custody/net-kingdom/platform-root')
progress('current_baseline_check')
check_baseline(actual,state,validator)
result.update(record_id=RECORD_ID,revision=state['revision'],kv_version=state['kv_version'],
digest=state['digest'],current_live_baseline_matches=True)
progress('backup_lane_read')
lane=bao.read(LANE)
result['lane_kv_version']=lane['metadata']['version']
progress('backup_operator_read')
operator=bao.read(OPERATOR_PATH)['data']
progress('historical_share_comparison')
comparison=historical_config_comparison(Path('/home/worsch/.config/rclone/rclone.conf'),lane['data'])
result.update(comparison)
if comparison['historical_share_matches_current_lane'] is True:
raise FinishFailure('current_backup_lane_incident')
receipt=roundtrip(bundle,lane,operator,progress=progress)
result.update(ok=True,status='verified',stage='complete',finished_at=datetime.now(timezone.utc).isoformat(),
scope='current-owner-read-and-governed-history-provider-roundtrip',
history_entries=len(json.loads(bundle)['history']),**receipt)
atomic_json(args.receipt,result)
return 0
except FileExistsError:
# Never change an old success receipt or claim it belongs to this attempt.
return 1
except Exception as error:
result.update(status='failed',finished_at=datetime.now(timezone.utc).isoformat(),error_code=error.code if isinstance(error,FinishFailure) and error.code in ERROR_CODES else 'stage_failed')
try:
atomic_json(args.receipt,result)
except Exception:
pass
return 1
if __name__=='__main__':
raise SystemExit(main())