Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
398 lines
22 KiB
Python
398 lines
22 KiB
Python
#!/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'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
|
|
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'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',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']<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 = {
|
|
'repair_inventory','repair_share_create','repair_share_verified','lane_cas','lane_readback', 'share_inventory','share_route_probes', '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 = {'lane_version_changed','repair_share_ambiguous','repair_share_scope_invalid','repair_folder_unverified','repair_share_changed','lane_cas_receipt_invalid','lane_readback_mismatch','repair_confirmation_required','share_inventory_invalid','share_probe_invalid','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()
|
|
modes=p.add_mutually_exclusive_group()
|
|
modes.add_argument('--inspect-share',action='store_true')
|
|
modes.add_argument('--repair-share',action='store_true')
|
|
p.add_argument('--expected-lane-version',type=int)
|
|
p.add_argument('--confirm-repair')
|
|
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=='provider_proof' and isinstance(value,dict):
|
|
for name in ('provider','artifact','upload_http_status','readback_http_status','ciphertext_sha256','decrypted_history_matches','independent_escrow_recipient_matches_current_lane','independent_escrow_decryption_verified'):
|
|
if name in value:result[name]=value[name]
|
|
result['provider_roundtrip_verified_before_cas']=True
|
|
continue
|
|
if key in {'replacement_share_created','lane_cas_noop'} and type(value) is bool:
|
|
result[key]=value
|
|
continue
|
|
if key in {'replacement_share_id','published_lane_kv_version'} and type(value) is int and value>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())
|