diff --git a/openbao/root-grant/README.md b/openbao/root-grant/README.md index 2f1a4d3..5cc6aae 100644 --- a/openbao/root-grant/README.md +++ b/openbao/root-grant/README.md @@ -237,3 +237,20 @@ documented `/public.php/dav/files/{token}` is probed read-only, with and without Basic auth. Protected shares require their password per official documentation; no password is inferred or changed. A later repair must follow diagnosed owner procedure; inspection cannot change lane, shares or provider files. + +Actual read-only inventory found neither token nor label in exact folder at +lane KV3. This is not proof of deletion or absence in subfolders. Repair mode +checks all Backup account-owned shares for label ambiguity, verifies exact +folder and account before creating only the reviewed create-only public link. +It requires explicit `--repair-share --expected-lane-version 3 --confirm-repair +"RESTORE Backup CREATE ONLY"`. Existing or new candidate must have exact +owner/path/label/public type/permissions4, known absent password and expiry. +Known share re-read must match. Then actual retained history is encrypted, +uploaded through documented public files route with required header, downloaded +as Backup, independently escrow-decrypted and compared BEFORE CAS. Only token +and URL are changed; every other lane field and all old shares/files preserved. +Fresh receipt keeps share ID and provider proof if subsequent CAS/readback fails. +On retry matching reviewed candidate is reused; stale expected version refuses. +PROPFIND denial alone does not prove grant invalid: create-only policy can deny +read properties. Backup consumers need normal governed credential refresh after +cutover; this is separate from custody history proof and no live rollout occurs. diff --git a/scripts/root_custody_finish.py b/scripts/root_custody_finish.py index e832ac6..507239b 100644 --- a/scripts/root_custody_finish.py +++ b/scripts/root_custody_finish.py @@ -3,6 +3,7 @@ import argparse import configparser import hmac +import re from urllib.parse import urlsplit,unquote from datetime import datetime,timezone import hashlib @@ -120,6 +121,84 @@ def inspect_share(lane, operator, inventory=None, network=None, progress=lambda '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 @@ -129,7 +208,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam 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]: + 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): @@ -183,7 +262,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam STAGES = { - 'share_inventory','share_route_probes', 'startup', 'attended_envelope', 'operator_endpoint', 'canon_pins', 'validator_import', + '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', @@ -196,7 +275,7 @@ class FinishFailure(ValueError): self.code = code -ERROR_CODES = {'share_inventory_invalid','share_probe_invalid','stage_failed', 'attended_envelope_required', 'approved_operator_endpoint_required', +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', @@ -206,7 +285,11 @@ ERROR_CODES = {'share_inventory_invalid','share_probe_invalid','stage_failed', ' def main(receipt_reserved=False): p=argparse.ArgumentParser() - p.add_argument('--inspect-share',action='store_true') + 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) @@ -218,6 +301,17 @@ def main(receipt_reserved=False): 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: @@ -277,7 +371,12 @@ def main(receipt_reserved=False): finished_at=datetime.now(timezone.utc).isoformat(),**diagnostic) atomic_json(args.receipt,result) return 0 - receipt=roundtrip(bundle,lane,operator,progress=progress) + 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) diff --git a/tests/test_root_custody_finish.py b/tests/test_root_custody_finish.py index 0760a2a..304c908 100644 --- a/tests/test_root_custody_finish.py +++ b/tests/test_root_custody_finish.py @@ -305,3 +305,121 @@ class ShareInspectionTests(unittest.TestCase): network=unittest.mock.Mock(side_effect=AssertionError('NO-AUTH-RETRY')) result=finish.inspect_share(lane,operator,inventory=lambda *args:shares,network=network) self.assertTrue(result['public_route_probes_skipped']);network.assert_not_called() + +class ShareRepairTests(unittest.TestCase): + def setUp(self): + self.case=MainIntegrationTests('test_full_attended_main_success_real_crypto_fake_owner_and_provider');self.case.setUp() + self.share={'id':'42','label':finish.LABEL,'token':'NEWTESTTOKEN','uid_owner':'Backup','path':finish.FOLDER,'permissions':4,'share_type':3,'password':None,'expiration':None} + self.existing=[];self.ocs_calls=[];self.write_calls=[] + def tearDown(self):self.case.tearDown() + def inventory(self,auth,suffix='',method='GET',fields=None): + self.ocs_calls.append((suffix,method,fields)) + if method=='POST':self.existing=[self.share];return dict(self.share) + if suffix:return [dict(self.share)] + return self.existing + def provider(self,url,method='GET',body=None,auth=None,headers=None): + if method=='PROPFIND':return 207,b'' + if method=='PUT': + self.assertIn('/public.php/dav/files/NEWTESTTOKEN/',url) + self.assertEqual(headers['X-Requested-With'],'XMLHttpRequest') + return self.case.provider(url,method,body,auth,headers) + def write(self,path,payload): + self.write_calls.append(payload) + self.assertEqual(payload['options']['cas'],4) + self.assertEqual(payload['data']['AGE_PRIVATE_KEY'],self.case.lane['data']['AGE_PRIVATE_KEY']) + self.case.lane={'data':payload['data'],'metadata':{'version':5}} + return {'data':{'version':5}} + def invoke(self,provider=None): + self.case.args += ['--repair-share','--expected-lane-version','4','--confirm-repair','RESTORE Backup CREATE ONLY'] + with patch.object(finish,'ocs',side_effect=self.inventory),patch.object(finish.Bao,'write',side_effect=self.write): + return self.case.invoke(provider=provider or self.provider) + def test_full_main_create_prove_then_coordinates_only_cas_real_crypto(self): + self.assertEqual(self.invoke(),0) + receipt=json.loads(self.case.receipt.read_text()) + self.assertTrue(receipt['provider_roundtrip_verified_before_cas']);self.assertEqual(receipt['published_lane_kv_version'],5) + self.assertTrue(receipt['escrow_and_unrelated_fields_preserved']);self.assertFalse(receipt['runtime_consumers_refreshed']) + self.assertEqual(len(self.write_calls),1) + self.assertFalse(any(x[1]=='DELETE' for x in self.ocs_calls)) + self.assertNotIn('NEWTESTTOKEN',json.dumps(receipt)) + def test_provider_failure_preserves_created_share_refuses_cas(self): + def bad_provider(*args):return (207,b'') if args[1]=='PROPFIND' else (401,b'') + self.assertEqual(self.invoke(provider=bad_provider),1) + receipt=json.loads(self.case.receipt.read_text()) + self.assertEqual(receipt['replacement_share_id'],42);self.assertTrue(receipt['replacement_share_created']) + self.assertEqual(receipt['upload_http_status'],401);self.assertEqual(self.write_calls,[]) + self.assertEqual(len(self.existing),1) + def test_existing_candidate_reused_and_same_coordinates_cas_noop(self): + self.existing=[self.share] + self.case.lane['data'].update(NC_WEBDAV_TOKEN='NEWTESTTOKEN',NC_WEBDAV_URL=finish.HOST+'/public.php/dav/files/NEWTESTTOKEN') + self.assertEqual(self.invoke(),0) + receipt=json.loads(self.case.receipt.read_text());self.assertTrue(receipt['lane_cas_noop']) + self.assertEqual(self.write_calls,[]);self.assertFalse(any(x[1]=='POST' for x in self.ocs_calls)) + def test_ambiguous_share_and_stale_version_refuse_mutations(self): + self.existing=[self.share,self.share] + self.assertEqual(self.invoke(),1);self.assertEqual(self.write_calls,[]) + self.assertFalse(any(x[1]=='POST' for x in self.ocs_calls)) + def test_cas_failure_keeps_successful_provider_receipt(self): + with patch.object(finish.Bao,'write',side_effect=RuntimeError('PRIVATE')): + # invoke's inner patch would shadow ours; patch bounded callback instead. + original=self.write + self.write=lambda *args:(_ for _ in ()).throw(RuntimeError('PRIVATE')) + self.assertEqual(self.invoke(),1) + self.write=original + receipt=json.loads(self.case.receipt.read_text()) + self.assertEqual(receipt['stage'],'lane_cas');self.assertTrue(receipt['provider_roundtrip_verified_before_cas']) + self.assertTrue(receipt['decrypted_history_matches']);self.assertNotIn('PRIVATE',json.dumps(receipt)) + def test_partial_cas_readback_failure_retains_written_version(self): + original=self.case.read + def read(path): + if path==finish.LANE and self.write_calls:raise RuntimeError('PRIVATE') + return original(path) + self.case.read=read + self.assertEqual(self.invoke(),1) + receipt=json.loads(self.case.receipt.read_text());self.assertEqual(receipt['stage'],'lane_readback') + self.assertEqual(receipt['published_lane_kv_version'],5);self.assertTrue(receipt['provider_roundtrip_verified_before_cas']) + + def test_stale_expected_version_refuses_before_provider_or_share_mutation(self): + inventory=unittest.mock.Mock();network=unittest.mock.Mock();bao=unittest.mock.Mock() + with self.assertRaises(finish.FinishFailure) as raised: + finish.repair_share(b'{}',self.case.lane,self.case.operator['data'],bao,3,inventory=inventory,network=network) + self.assertEqual(raised.exception.code,'lane_version_changed') + inventory.assert_not_called();network.assert_not_called();bao.write.assert_not_called() + +class ShareRepairWireTests(unittest.TestCase): + def test_actual_ocs_form_and_singleton_reread_and_canonical_public_put(self): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import parse_qs + import threading + import migrate_nextcloud_backup_account as migration + calls=[] + share={'id':'42','token':'DISPOSABLE'} + class Handler(BaseHTTPRequestHandler): + def log_message(self,*args):pass + def respond(self,value): + self.send_response(200);self.end_headers() + self.wfile.write(json.dumps({'ocs':{'meta':{'statuscode':100},'data':value}}).encode()) + def do_POST(self): + fields=parse_qs(self.rfile.read(int(self.headers['Content-Length'])).decode(),keep_blank_values=True) + calls.append(('POST',self.path,fields,dict(self.headers)));self.respond(share) + def do_GET(self): + calls.append(('GET',self.path,None,dict(self.headers)));self.respond([share]) + def do_PUT(self): + self.rfile.read(int(self.headers['Content-Length'])) + calls.append(('PUT',self.path,None,dict(self.headers))) + self.send_response(201);self.end_headers() + server=ThreadingHTTPServer(('127.0.0.1',0),Handler) + worker=threading.Thread(target=server.serve_forever,daemon=True);worker.start() + origin='http://127.0.0.1:'+str(server.server_port) + try: + with patch.object(migration,'HOST',origin): + created=migration.ocs(('Backup','FAKE'),method='POST',fields={'path':finish.FOLDER,'shareType':3,'permissions':4,'label':finish.LABEL,'sendMail':'false','expireDate':''}) + self.assertEqual(created,share) + self.assertEqual(migration.ocs(('Backup','FAKE'),'/42'),[share]) + code,_=migration.request(origin+'/public.php/dav/files/DISPOSABLE/forgejo/new.age','PUT',b'cipher',('DISPOSABLE',''),{'If-None-Match':'*'}) + self.assertEqual(code,201) + self.assertEqual(calls[0][2],{'path':[finish.FOLDER],'shareType':['3'],'permissions':['4'],'label':[finish.LABEL],'sendMail':['false'],'expireDate':['']}) + self.assertEqual({k.lower():v for k,v in calls[0][3].items()}['ocs-apirequest'],'true') + self.assertTrue(calls[1][1].endswith('/shares/42')) + self.assertEqual(calls[2][3]['X-Requested-With'],'XMLHttpRequest') + self.assertEqual(calls[2][3]['If-None-Match'],'*') + finally:server.shutdown();server.server_close();worker.join() diff --git a/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md b/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md index 4d56637..16616fd 100644 --- a/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md +++ b/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md @@ -229,3 +229,15 @@ output is booleans/status classifications; no files/shares/credentials/KV change T05 remains blocked on diagnosed provider route/share binding and subsequent actual recovery proof. Existing approvals cover bounded read-only inspection; operator MFA is execution dependency. No automatic repair/fallback/rotation. + +## Diagnosed bounded share recovery prepared — 2026-10-03 + +Actual inspection19:36 UTC laneKV3 found no bound token or reviewed label in +exact folder. Parent retains safe receipt; not claimed globally deleted. +Prepared --repair-share owner procedure checks whole Backup-owned inventory, +refuses ambiguity or scope/protection/expiry mismatch, verifies account/folder, +creates only missing reviewed create-only share and re-reads it. Retained history +roundtrip and independent escrow proof precede exact CAS3 coordinates-only +cutover; unknown shares/old backups preserved. Provider proof and partial CAS +receipt retained. T05 waits bounded attended repair/recovery receipt; consumer +refresh after lane rotation remains separately owned backup requirement.