#!/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, 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).replace(tzinfo=timezone.utc) expired=parsed<=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.get('password')), current_share_expiry_present=bool(expiry),current_share_expired=expired) 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 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']