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 5d2651e..8052cb2 100644 --- a/docs/evidence/2026-10-03-platform-root-custody-delivery.md +++ b/docs/evidence/2026-10-03-platform-root-custody-delivery.md @@ -66,3 +66,43 @@ Security orientation reloaded: Emergent 0.2.4 root, verified-files, orientation-only; digest 025aab4f38246219c924bc94954e70ea0e6e7f72bcaf8d4344cd599602d345c3. No secret values, privileged role dump or token material are copied here. + +## Completed isolated recovery proof + +Actual disposable local Raft server: OpenBao v2.5.5. Evidence: +[isolated recovery receipt](2026-10-03-root-custody-isolated-raft-recovery.json). +The exact owner publisher and baseline guard ran against an ephemeral loopback +server and synthetic local credentials, with the real retained non-secret +empty document. Two native Raft snapshots were created. Restoring the snapshot +from before publication caused the missing source to be refused; restoring the +snapshot containing revision 1/KV1 matched the independent retained digest. +Workstation baseline/history copy and scratch restoration matched exactly, +including 0600 baseline permissions. Server, credentials and snapshots were +confined to temporary storage and cleaned up. Production was not accessed or +mutated and no production snapshot was read. + +This completes the bounded algorithm/native-backend isolated rollback/restore +exercise and local receipt-holder copy/restore proof. It does not claim a +production snapshot recovery or actual workstation backup-provider readback. +The current live retained-baseline restore-check, provider retention coverage +and workload/audit receipts remain separately tracked. + +## Established retention path and attended finish + +No configured workstation backup job covering the new publisher directory was +found in the inspected owner backup contracts/local backup configuration. A +validated non-secret recovery copy now lives in root-custody-history/ in this +owner repository. This enters the established Forgejo Git source backup scope; +new provider archive coverage is not implied until an actual archive includes +the commit. Source retrieval/readback is tracked separately from provider proof. + +The one-command attended finish packet is in openbao/root-grant/README.md. +It performs current owner baseline readback and an age-encrypted history +upload/download/decrypt equality proof through the current governed OpenBao +Nextcloud backup account/lane only. It uses existing forgejo/ storage, no new +credential, no policy widening and no local historical rclone grant. A local +historical configuration's credential-bearing coordinates may have appeared in +backup-discovery tool output. No values are retained here. The finish helper +compares that grant with the current lane in memory; if current, it reports a +safe incident status and stops before upload for coordinated existing owner +credential cutover, preserving consumers. No uninformed rotation is performed. diff --git a/docs/evidence/2026-10-03-root-custody-isolated-raft-recovery.json b/docs/evidence/2026-10-03-root-custody-isolated-raft-recovery.json new file mode 100644 index 0000000..6816684 --- /dev/null +++ b/docs/evidence/2026-10-03-root-custody-isolated-raft-recovery.json @@ -0,0 +1,18 @@ +{ + "digest": "33a2ae086385028db1b654aa5646b7a98bcde1c5878c804e19315dadc3f87248", + "entries_count": 0, + "kv_version": 1, + "latest_snapshot_matches_independent_baseline": true, + "ok": true, + "old_snapshot_missing_source_refused": true, + "production_mutated": false, + "production_snapshot_used": false, + "receipt_holder_backup_provider_verified": false, + "receipt_holder_scratch_restore_verified": true, + "record_id": "nk-custody:platform-root", + "revision": 1, + "scope": "disposable-loopback-openbao-raft", + "server_version": "OpenBao v2.5.5 (028992583c693c4de6350b8aa52ff85e30375a99), built 2026-06-17T11:18:48Z", + "snapshot_count": 2, + "temporary_artifacts_removed": true +} diff --git a/docs/evidence/root-custody-history/README.md b/docs/evidence/root-custody-history/README.md new file mode 100644 index 0000000..4f868c1 --- /dev/null +++ b/docs/evidence/root-custody-history/README.md @@ -0,0 +1,20 @@ +# Non-authoritative publisher history recovery copy + +This directory contains only the validated non-secret empty publication baseline +and immutable history receipt. It is an independent recovery copy, not a read +surface or a ceremony, and must never mint a grant. Runtime reads OpenBao only. +Retain every newer publisher revision here; do not replace/remove old receipts. + +The approved owner Forgejo repository backup contract covers source/history; +this copy enters that established scope after commit/push. Exact remote Git +retrieval/readback can prove this copy survives loss of the local publisher +files. A fresh Nextcloud/Scaleway Forgejo archive containing this commit is a +separate provider-generation proof and is not implied by push or Git readback. +The attended finish helper instead directly encrypts retained publisher history +and verifies upload/download/decryption using the current governed Nextcloud +lane, reusing established owner storage and adding no new service or credential. + +Controlled restoration copies baseline.json and history/ to the private operator +state directory, preserves mode0600 and independently compares the latest +record/digest/KV version against authenticated OpenBao readback. An older +snapshot alone cannot reset the independent high-water record. diff --git a/docs/evidence/root-custody-history/baseline.json b/docs/evidence/root-custody-history/baseline.json new file mode 100644 index 0000000..0493f0d --- /dev/null +++ b/docs/evidence/root-custody-history/baseline.json @@ -0,0 +1,21 @@ +{ + "digest": "33a2ae086385028db1b654aa5646b7a98bcde1c5878c804e19315dadc3f87248", + "document": { + "entries": [], + "freshness": { + "max_cache_seconds": 900, + "on_unreadable": "no-grant" + }, + "kind": "netkingdom-custody-record", + "owner_system": "net-kingdom-custody", + "record_id": "nk-custody:platform-root", + "revision": 1, + "schema_version": "0.1", + "updated_at": "2026-10-03T17:47:04.035713Z" + }, + "kv_version": 1, + "provenance_ref": "net-kingdom/docs/evidence/2026-10-03-root-grant-delivery-progress.md", + "record_id": "nk-custody:platform-root", + "recorded_at": "2026-10-03T18:21:14.370633+00:00", + "revision": 1 +} diff --git a/docs/evidence/root-custody-history/history/1-33a2ae086385028db1b654aa5646b7a98bcde1c5878c804e19315dadc3f87248.json b/docs/evidence/root-custody-history/history/1-33a2ae086385028db1b654aa5646b7a98bcde1c5878c804e19315dadc3f87248.json new file mode 100644 index 0000000..0493f0d --- /dev/null +++ b/docs/evidence/root-custody-history/history/1-33a2ae086385028db1b654aa5646b7a98bcde1c5878c804e19315dadc3f87248.json @@ -0,0 +1,21 @@ +{ + "digest": "33a2ae086385028db1b654aa5646b7a98bcde1c5878c804e19315dadc3f87248", + "document": { + "entries": [], + "freshness": { + "max_cache_seconds": 900, + "on_unreadable": "no-grant" + }, + "kind": "netkingdom-custody-record", + "owner_system": "net-kingdom-custody", + "record_id": "nk-custody:platform-root", + "revision": 1, + "schema_version": "0.1", + "updated_at": "2026-10-03T17:47:04.035713Z" + }, + "kv_version": 1, + "provenance_ref": "net-kingdom/docs/evidence/2026-10-03-root-grant-delivery-progress.md", + "record_id": "nk-custody:platform-root", + "recorded_at": "2026-10-03T18:21:14.370633+00:00", + "revision": 1 +} diff --git a/openbao/root-grant/README.md b/openbao/root-grant/README.md index 23767f5..aa1c08c 100644 --- a/openbao/root-grant/README.md +++ b/openbao/root-grant/README.md @@ -158,3 +158,37 @@ revision 1/KV1/no-grant digest and exact residual recovery/reader evidence are recorded in [delivery evidence](../../docs/evidence/2026-10-03-platform-root-custody-delivery.md). This completes RPF-WP-0049-T04. T05 remains evidence-blocked; generic approval is not pending. Old operator role configuration is not an execution receipt. + +## Remaining bounded attended finish + +Native isolated Raft recovery and local receipt-holder scratch restoration now +pass. The operator-owned directory has no discovered workstation backup job. +The non-authoritative recovery copy in docs/evidence/root-custody-history enters +the existing owner Forgejo source backup scope; that does not imply a fresh +provider archive has included this new commit. One attended command completes +current live baseline check plus direct protected history provider readback: + +```bash +BAO_ADDR=http://127.0.0.1:18200 VAULT_ADDR=http://127.0.0.1:18200 \ + warden access openbao-platform-admin-login --exec -- \ + /home/worsch/railiance-platform/scripts/root-custody-finish-attended.sh \ + --state /home/worsch/.local/state/net-kingdom-root-custody/baseline.json \ + --net-kingdom /home/worsch/net-kingdom \ + --receipt /tmp/platform-root-finish-UNIQUE-RUN.json +``` + +The silent child uses the current governed OpenBao backup account/lane only, +requires the known Backup owner and create-only share shape, checks quota, +encrypts history with current escrow, creates one uniquely named artifact under +the existing forgejo/ backup folder, downloads through operator authority and +verifies ciphertext and decrypted history equality. It deletes nothing and +changes no credential or policy. Read the fresh receipt; old receipts are refused. + +A local historical rclone configuration was found during backup discovery and +its credential-bearing coordinates may have appeared in tool output. It is +never reused. The helper compares that share with the current lane in memory +and records only booleans. If it is the current lane, the helper stops before +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. diff --git a/scripts/root-custody-finish-attended.sh b/scripts/root-custody-finish-attended.sh new file mode 100755 index 0000000..7c4ed1f --- /dev/null +++ b/scripts/root-custody-finish-attended.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec python3 "$(dirname "$0")/root_custody_finish.py" "$@" >/dev/null 2>&1 diff --git a/scripts/root_custody_finish.py b/scripts/root_custody_finish.py new file mode 100644 index 0000000..033f156 --- /dev/null +++ b/scripts/root_custody_finish.py @@ -0,0 +1,133 @@ +#!/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' + + +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') + 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') + 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') + history.append(receipt) + if state not in history: + raise ValueError('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=request, get_quota=quota): + values=lane['data'] + if operator['BACKUP_USERNAME']!='Backup': + raise ValueError('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') + key=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode() + def process(command,payload): + result=subprocess.run(command,input=payload,capture_output=True,timeout=30) + if result.returncode: + raise ValueError('protected_process_failed') + return result.stdout + recipient=process(['age-keygen','-y','/dev/stdin'],key).decode().strip() + encrypted=process(['age','-r',recipient],bundle) + if len(encrypted)>1048576 or get_quota(auth)['available_bytes']