From 4dd9646ec21b63a7743dae0ad3273da2f73adefe Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 3 Oct 2026 21:28:37 +0200 Subject: [PATCH] feat(backup): inspect governed share before recovery retry Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29 --- openbao/root-grant/README.md | 10 +++ scripts/root_custody_finish.py | 71 ++++++++++++++++++- tests/test_root_custody_finish.py | 44 ++++++++++++ ...9-platform-root-custody-record-delivery.md | 13 ++++ 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/openbao/root-grant/README.md b/openbao/root-grant/README.md index 5c56ebd..2f1a4d3 100644 --- a/openbao/root-grant/README.md +++ b/openbao/root-grant/README.md @@ -227,3 +227,13 @@ changes request protocol only; preserve lane/token/permissions and do not rotate credentials or fall back to wider account uploads. Failure receipts now retain current lane KV version. The historical exposed share differs from current lane; separate historical owner/revocation evidence remains unverified. + +The corrected-header retry still returned401 against lane KVversion3. Before +another upload use the same attended wrapper with `--inspect-share` and a fresh +receipt path. This performs only owner-scoped existing-share OCS inspection and +Depth0 public DAV route probes; successful diagnostic receipt explicitly sets +`backup_roundtrip_proved=false`. Current route uses reviewed lane coordinates; +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. diff --git a/scripts/root_custody_finish.py b/scripts/root_custody_finish.py index 5688095..fbd6cee 100644 --- a/scripts/root_custody_finish.py +++ b/scripts/root_custody_finish.py @@ -15,7 +15,7 @@ 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 +from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, LANE, FOLDER, LABEL, quota, request, ocs VALIDATOR_DIGEST='9156e399c8d92a45b817bfc3738c89843487ce57e1718ed747f804f95cc719bb' SCHEMA_DIGEST='bb6cd6da5dc48eddcf118ae78c2cacfafd5f860098fd8a8368fe6bc845776d07' @@ -55,6 +55,63 @@ def historical_config_comparison(config_path, values): "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 @@ -118,7 +175,7 @@ def roundtrip(bundle, lane, operator, network=None, get_quota=None, progress=lam STAGES = { - 'startup', 'attended_envelope', 'operator_endpoint', 'canon_pins', 'validator_import', + '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', @@ -131,7 +188,7 @@ class FinishFailure(ValueError): self.code = code -ERROR_CODES = {'stage_failed', 'attended_envelope_required', 'approved_operator_endpoint_required', +ERROR_CODES = {'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', @@ -141,6 +198,7 @@ ERROR_CODES = {'stage_failed', 'attended_envelope_required', 'approved_operator_ def main(receipt_reserved=False): p=argparse.ArgumentParser() + p.add_argument('--inspect-share',action='store_true') 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) @@ -204,6 +262,13 @@ def main(receipt_reserved=False): 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 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', diff --git a/tests/test_root_custody_finish.py b/tests/test_root_custody_finish.py index 595dd9b..894d752 100644 --- a/tests/test_root_custody_finish.py +++ b/tests/test_root_custody_finish.py @@ -244,3 +244,47 @@ class PublicDavWireTests(unittest.TestCase): self.assertNotIn('X-Requested-With',seen[1]) finally: server.shutdown();server.server_close();worker.join() + +class ShareInspectionTests(unittest.TestCase): + def fixture(self): + return ({'data':{'NC_WEBDAV_URL':finish.HOST+'/public.php/dav/filesdrop/TEST-TOKEN','NC_WEBDAV_TOKEN':'TEST-TOKEN'},'metadata':{'version':3}}, + {'BACKUP_USERNAME':'Backup','BACKUP_PASSWORD':'DO-NOT-OUTPUT'}, + [{'label':finish.LABEL,'token':'TEST-TOKEN','uid_owner':'Backup','path':finish.FOLDER,'permissions':4,'share_type':3,'password':None,'expiration':None}]) + def test_existing_share_scope_and_probe_routes_no_mutation_or_secret_output(self): + lane,operator,shares=self.fixture();calls=[] + def probe(url,method,body,auth,headers): + calls.append((url,method,auth));self.assertEqual(method,'PROPFIND') + self.assertEqual(headers['Depth'],'0');self.assertEqual(headers['X-Requested-With'],'XMLHttpRequest') + return (401,b'RAW-MUST-NOT-OUTPUT') if '/filesdrop/' in url else (403,b'RAW-MUST-NOT-OUTPUT') + result=finish.inspect_share(lane,operator,inventory=lambda *args:shares,network=probe) + self.assertTrue(result['current_share_create_only']);self.assertTrue(result['current_share_owner_matches']) + self.assertEqual(result['current_route_classification'],'authentication_rejected') + self.assertEqual(result['documented_route_classification'],'capability_denied') + self.assertIsNone(calls[-1][2]);self.assertFalse(result['provider_mutations_performed']) + for secret in ('TEST-TOKEN','DO-NOT-OUTPUT','RAW-MUST-NOT-OUTPUT'): + self.assertNotIn(secret,json.dumps(result)) + def test_missing_and_ambiguous_share_do_not_assume_authority(self): + lane,operator,shares=self.fixture() + for inventory in ([],shares+shares): + result=finish.inspect_share(lane,operator,inventory=lambda *args:inventory,network=lambda *args:(401,b'')) + self.assertFalse(result['current_token_unique']);self.assertNotIn('current_share_create_only',result) + def test_password_and_expiry_indicators_do_not_emit_values(self): + lane,operator,shares=self.fixture();shares[0].update(password='PRIVATE-HASH',expiration='2000-01-01 00:00:00') + result=finish.inspect_share(lane,operator,inventory=lambda *args:shares,network=lambda *args:(401,b'')) + self.assertTrue(result['current_share_password_indicator']);self.assertTrue(result['current_share_expired']) + self.assertNotIn('PRIVATE-HASH',json.dumps(result)) + def test_main_diagnostic_cannot_call_upload(self): + case=MainIntegrationTests('test_full_attended_main_success_real_crypto_fake_owner_and_provider') + case.setUp() + try: + case.args.append('--inspect-share') + lane,operator,shares=self.fixture() + calls=[] + def network(*args):calls.append(args);return (403,b'') + with patch.object(finish,'ocs',return_value=shares),patch.object(finish,'roundtrip',side_effect=AssertionError('UPLOAD-FORBIDDEN')): + self.assertEqual(case.invoke(provider=network),0) + receipt=json.loads(case.receipt.read_text()) + self.assertTrue(receipt['ok']);self.assertFalse(receipt['backup_roundtrip_proved']) + self.assertEqual(receipt['scope'],'read-only-current-governed-share-inspection') + self.assertTrue(all(x[1]=='PROPFIND' for x in calls)) + finally:case.tearDown() 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 ac20737..4d56637 100644 --- a/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md +++ b/workplans/RPF-WP-0049-platform-root-custody-record-delivery.md @@ -216,3 +216,16 @@ header and unchanged owner read request. This explains a plausible rejection mechanism, not proved live causality. T05 remains blocked on a fresh attended finish receipt from corrected source; shared workload recovery proof must also be refreshed after normal source deployment, not inferred from this test. + +## Header retry still rejected; read-only diagnosis required — 2026-10-03 + +Actual corrected-header retry at 19:23 UTC still returned401, lane KVversion3, +with current source and independent escrow checks passing. Header fix is valid +but not sole cause. Do not blindly repeat backup. Added --inspect-share mode: +owner-scoped existing OCS inventory, exact reviewed label/path/owner/create-only +permissions/token binding/expiry/password indicators, and Depth0 PROPFIND of +current and officially documented public DAV routes with/without Basic. All +output is booleans/status classifications; no files/shares/credentials/KV changed. +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.