#!/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']