#!/usr/bin/env python3 """Silent attended current-source and governed provider history recovery proof.""" import argparse import configparser import hmac import re 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, FOLDER, LABEL, quota, request, ocs 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 inspect_share(lane, operator, inventory=None, network=None, progress=lambda stage, **details: None): """Owner metadata and depth-zero public probes only; never upload or change shares.""" inventory=ocs if inventory is None else inventory network=request if network is None else network values=lane['data'] if operator['BACKUP_USERNAME']!='Backup': raise FinishFailure('governed_owner_mismatch') token=values['NC_WEBDAV_TOKEN'] upload=values['NC_WEBDAV_URL'] if not token or upload != HOST+'/public.php/dav/filesdrop/'+token: raise FinishFailure('current_lane_contract_mismatch') auth=(operator['BACKUP_USERNAME'],operator['BACKUP_PASSWORD']) progress('share_inventory') # Existing owner-scoped inventory, filtered provider-side to reviewed folder. from urllib.parse import quote shares=inventory(auth,'?path='+quote(FOLDER,safe='')) if not isinstance(shares,list) or len(shares)>1000: raise FinishFailure('share_inventory_invalid') labelled=[x for x in shares if x.get('label')==LABEL] bound=[x for x in shares if isinstance(x.get('token'),str) and hmac.compare_digest(x['token'],token)] result={'labelled_share_present':bool(labelled),'labelled_share_unique':len(labelled)==1, 'current_token_found_in_reviewed_folder':bool(bound),'current_token_unique':len(bound)==1, 'read_only_diagnostic':True,'provider_mutations_performed':False,'backup_roundtrip_proved':False} if len(bound)==1: share=bound[0] expiry=share.get('expiration') expired=None if expiry: try: parsed=datetime.fromisoformat(expiry) if parsed.tzinfo is not None: expired=parsed.astimezone(timezone.utc)<=datetime.now(timezone.utc) except (TypeError,ValueError):pass result.update(current_share_label_matches=share.get('label')==LABEL, current_share_owner_matches=share.get('uid_owner')=='Backup', current_share_path_matches=share.get('path')==FOLDER, current_share_public_link=share.get('share_type') in (3,'3'), current_share_create_only=share.get('permissions') in (4,'4'), current_share_password_indicator=bool(share['password']) if 'password' in share else None, current_share_password_indicator_known='password' in share, current_share_expiry_present=bool(expiry),current_share_expired=expired, current_share_expiry_clock_basis_known=not bool(expiry) or expired is not None) if result['current_share_password_indicator'] is True or expired is True: result['public_route_probes_skipped']=True result['public_route_skip_reason']='password_protected' if result['current_share_password_indicator'] is True else 'expired' return result result['public_route_probes_skipped']=False progress('share_route_probes') body=b'' headers={'Depth':'0','Content-Type':'application/xml','X-Requested-With':'XMLHttpRequest'} # No listing of children, no PUT; response bodies remain captured and discarded. for name,url,credentials in ( ('current_route',upload,(token,'')), ('documented_route',HOST+'/public.php/dav/files/'+token,(token,'')), ('documented_route_no_basic',HOST+'/public.php/dav/files/'+token,None)): code,_=network(url,'PROPFIND',body,credentials,headers) if type(code) is not int or not 100<=code<=599: raise FinishFailure('share_probe_invalid') result[name+'_http_status']=code result[name+'_classification']=('authentication_rejected' if code==401 else 'capability_denied' if code==403 else 'not_found' if code==404 else 'properties_returned' if code==207 else 'other_status') return result def repair_share(bundle, lane, operator, bao, expected_version, inventory=None, network=None, progress=lambda stage, **details: None): """Bounded owner cutover: proof first, CAS coordinates only, preserve all old shares.""" inventory=ocs if inventory is None else inventory network=request if network is None else network if lane['metadata']['version']!=expected_version: raise FinishFailure('lane_version_changed') if operator['BACKUP_USERNAME']!='Backup': raise FinishFailure('governed_owner_mismatch') auth=(operator['BACKUP_USERNAME'],operator['BACKUP_PASSWORD']) progress('repair_inventory') # All current account-owned shares: detect label ambiguity outside exact folder too. shares=inventory(auth) if not isinstance(shares,list) or len(shares)>1000: raise FinishFailure('share_inventory_invalid') labelled=[x for x in shares if x.get('label')==LABEL] if len(labelled)>1: raise FinishFailure('repair_share_ambiguous') def verify(share): if not (share.get('uid_owner')=='Backup' and share.get('path')==FOLDER and share.get('label')==LABEL and share.get('permissions') in (4,'4') and share.get('share_type') in (3,'3') and 'password' in share and share['password'] in (None,'',False) and 'expiration' in share and share['expiration'] in (None,'') and isinstance(share.get('token'),str) and re.fullmatch(r'[A-Za-z0-9_-]+',share['token']) and isinstance(share.get('id'),(str,int)) and str(share['id']).isdigit()): raise FinishFailure('repair_share_scope_invalid') if labelled: share=labelled[0];verify(share) progress('repair_share_verified',replacement_share_created=False,replacement_share_id=int(share['id'])) else: # Recheck the exact reviewed directory and valid Backup account before creation. get_quota=quota get_quota(auth) code,_=network(HOST+'/remote.php/dav/files/Backup'+FOLDER,'PROPFIND', b'',auth, {'Depth':'0','Content-Type':'application/xml'}) if code!=207: raise FinishFailure('repair_folder_unverified') progress('repair_share_create') share=inventory(auth,method='POST',fields={'path':FOLDER,'shareType':3,'permissions':4,'label':LABEL,'sendMail':'false','expireDate':''}) if isinstance(share.get('id'),(str,int)) and str(share['id']).isdigit(): progress('repair_share_create',replacement_share_created=True,replacement_share_id=int(share['id'])) verify(share) # Re-read the one admitted share: never trust just POST response. admitted=inventory(auth,'/'+str(share['id'])) if not isinstance(admitted,list) or len(admitted)!=1: raise FinishFailure('repair_share_scope_invalid') admitted=admitted[0] verify(admitted) if not hmac.compare_digest(admitted['token'],share['token']): raise FinishFailure('repair_share_changed') progress('repair_share_verified',replacement_share_id=int(share['id'])) updated=dict(lane['data'],NC_WEBDAV_TOKEN=share['token'], NC_WEBDAV_URL=HOST+'/public.php/dav/files/'+share['token']) candidate={'data':updated,'metadata':lane['metadata']} proof=roundtrip(bundle,candidate,operator,network=network,progress=progress) # Archive remains stored on failure; no deletes of unknown shares/files. progress('lane_cas',provider_proof=proof) current=bao.read(LANE) if current!=lane: raise FinishFailure('lane_version_changed') if updated==lane['data']: progress('lane_cas',lane_cas_noop=True,published_lane_kv_version=expected_version) version=expected_version else: response=bao.write(LANE,{'options':{'cas':expected_version},'data':updated}) version=response['data']['version'] progress('lane_cas',lane_cas_noop=False,published_lane_kv_version=version) if version!=expected_version+1: raise FinishFailure('lane_cas_receipt_invalid') progress('lane_readback') readback=bao.read(LANE) if readback['metadata']['version']!=version or readback['data']!=updated: raise FinishFailure('lane_readback_mismatch') return dict(proof,published_lane_kv_version=version,coordinates_only_updated=True, escrow_and_unrelated_fields_preserved=True,unknown_shares_and_old_backups_preserved=True, runtime_consumers_refreshed=False) 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 upload not in (HOST+'/public.php/dav/filesdrop/'+values['NC_WEBDAV_TOKEN'],HOST+'/public.php/dav/files/'+values['NC_WEBDAV_TOKEN']): 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']0: result[key]=value continue 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') if args.inspect_share: diagnostic=inspect_share(lane,operator,progress=progress) result.update(ok=True,status='diagnostic-complete',stage='complete', scope='read-only-current-governed-share-inspection', finished_at=datetime.now(timezone.utc).isoformat(),**diagnostic) atomic_json(args.receipt,result) return 0 if args.repair_share: if args.confirm_repair!='RESTORE Backup CREATE ONLY' or args.expected_lane_version is None: raise FinishFailure('repair_confirmation_required') receipt=repair_share(bundle,lane,operator,bao,args.expected_lane_version,progress=progress) else: 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())