diff --git a/docs/evidence/2026-10-03-platform-root-custody-delivery.md b/docs/evidence/2026-10-03-platform-root-custody-delivery.md index 4fd74d3..94b9c90 100644 --- a/docs/evidence/2026-10-03-platform-root-custody-delivery.md +++ b/docs/evidence/2026-10-03-platform-root-custody-delivery.md @@ -139,3 +139,25 @@ approval applies; operator OIDC/MFA remains execution-only. Fresh receipt must show success. If the exposed historical local grant matches the current lane, coordinated existing credential recovery is required before provider proof; there is no automatic credential rotation or root authority change. + +## Failed attended finish and diagnostic correction + +Attempt185421Z333977 completed and revoked its operator login, but the finish +child failed without a receipt. Owner audit proves custody, backup-lane and +operator-backup reads all succeeded. The exact external-provider failure is +unknown because the previous catch-all discarded diagnostics; no IAM widening +or speculative protocol/status relaxation is warranted. Safe audit receipt: +2026-10-03-root-custody-failed-finish-audit.json. + +The corrected silent wrapper runs a stdlib bootstrap that reserves a fresh +private receipt before importing helper dependencies. Full main emits fixed +allowlisted stage/error codes and safe HTTP/artifact metadata on failure, +including partial owner-baseline success and any attempted created artifact. +It never emits exception text, raw response, credential or provider coordinates. +Private clean-environment full-main tests use the actual attended envelope +predicate, real validator/system age and fake owner/provider boundaries; they +cover success, denied owner read, quota, missing binary, upload, download, +ciphertext/decrypt failure, startup imports, stale receipt and incident stop. +A new fresh-path attended attempt can now identify the actual provider boundary +without guessing or repeating opaque failure. Actual provider acceptance remains +pending; simulated successful provider tests do not establish it. diff --git a/docs/evidence/2026-10-03-root-custody-failed-finish-audit.json b/docs/evidence/2026-10-03-root-custody-failed-finish-audit.json new file mode 100644 index 0000000..46afb61 --- /dev/null +++ b/docs/evidence/2026-10-03-root-custody-failed-finish-audit.json @@ -0,0 +1,28 @@ +{ + "events": [ + { + "error_present": false, + "permission_denied": false, + "request_id": "b29f3261-7fd3-84e9-7aa4-493205e469a4", + "stage": "custody_read", + "time": "2026-10-03T18:54:37.880715204Z" + }, + { + "error_present": false, + "permission_denied": false, + "request_id": "e474bc4b-9265-f3d9-d0f3-cdf6ec7eb038", + "stage": "backup_lane_read", + "time": "2026-10-03T18:54:38.14371374Z" + }, + { + "error_present": false, + "permission_denied": false, + "request_id": "d4c3df09-30e0-36f7-c66f-8d000e23dfe2", + "stage": "operator_backup_read", + "time": "2026-10-03T18:54:38.4374245Z" + } + ], + "lookup_succeeded": true, + "raw_logs_emitted": false, + "scope": "failed-attended-finish-stage-audit" +} diff --git a/openbao/root-grant/README.md b/openbao/root-grant/README.md index aa1c08c..ff9d3e8 100644 --- a/openbao/root-grant/README.md +++ b/openbao/root-grant/README.md @@ -192,3 +192,27 @@ upload and requires coordinated existing credential incident/cutover recovery, preserving consumers; it never rotates an uninformed predecessor. If different, the historical owner's revocation status remains unverified separately from root-grant authority. No credential-bearing coordinates are copied to evidence. + +## Finish failure receipts + +The first finish attempt failed after successful authenticated owner reads but +before a receipt. Its exact provider cause was lost by the old catch-all; it is +not evidence of missing IAM. The corrected wrapper reserves a fresh receipt +before helper imports and always records safe stage/error codes. Inspect that +receipt after execution even if the child returns nonzero. Failed upload or +readback includes safe numeric HTTP status and generated artifact name when +available; do not rerun with the same receipt path or weaken expected provider +contract/status checks blindly. No token, share coordinates, private key, raw +response or exception message is written. `helper_import` means startup failed; +`backup_operator_read` means current owner credential retrieval failed; +`provider_contract`, crypto, quota, upload/download or ciphertext/decrypt stages +identify the remaining provider proof boundary. Login cleanup and task success +remain separate, and only a verified fresh success receipt completes T05. + +The finish helper also uses the established independent operator escrow at +/home/worsch/.config/age/railiance-backup.key. It derives that key's public +recipient in a captured subprocess, compares it with the current governed lane +and decrypts the downloaded artifact with the independent key file. Only match +and decryption booleans are recorded; the private key is never read into logs +or replaced. A missing/different escrow is an explicit fixed-code recovery +failure, not a request to create or reset a key implicitly. diff --git a/scripts/root-custody-finish-attended.sh b/scripts/root-custody-finish-attended.sh index 7c4ed1f..8f69415 100755 --- a/scripts/root-custody-finish-attended.sh +++ b/scripts/root-custody-finish-attended.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash set -euo pipefail -exec python3 "$(dirname "$0")/root_custody_finish.py" "$@" >/dev/null 2>&1 +exec /usr/bin/python3 "$(dirname "$0")/root_custody_finish_entry.py" "$@" >/dev/null 2>&1 diff --git a/scripts/root_custody_finish.py b/scripts/root_custody_finish.py index 033f156..0568e91 100644 --- a/scripts/root_custody_finish.py +++ b/scripts/root_custody_finish.py @@ -19,22 +19,23 @@ from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, LANE, quota, r 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 ValueError('retained_state_invalid') + 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 ValueError('history_invalid') + 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 ValueError('history_invalid') + raise FinishFailure('history_invalid') history.append(receipt) if state not in history: - raise ValueError('latest_history_missing') + raise FinishFailure('latest_history_missing') return state,json.dumps({'baseline':state,'history':history},sort_keys=True,separators=(',',':')).encode() @@ -54,78 +55,169 @@ def historical_config_comparison(config_path, values): "historical_credential_status":"comparison-unavailable"} -def roundtrip(bundle, lane, operator, network=request, get_quota=quota): +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 ValueError('governed_owner_mismatch') + 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 ValueError('current_lane_contract_mismatch') + raise FinishFailure('current_lane_contract_mismatch') key=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode() def process(command,payload): - result=subprocess.run(command,input=payload,capture_output=True,timeout=30) + 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 ValueError('protected_process_failed') + 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']