Prove isolated custody recovery and prepare governed offsite finish
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
This commit is contained in:
parent
dff2d6e9f7
commit
a252447058
11 changed files with 528 additions and 1 deletions
|
|
@ -66,3 +66,43 @@ Security orientation reloaded: Emergent 0.2.4 root, verified-files,
|
||||||
orientation-only; digest
|
orientation-only; digest
|
||||||
025aab4f38246219c924bc94954e70ea0e6e7f72bcaf8d4344cd599602d345c3.
|
025aab4f38246219c924bc94954e70ea0e6e7f72bcaf8d4344cd599602d345c3.
|
||||||
No secret values, privileged role dump or token material are copied here.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
20
docs/evidence/root-custody-history/README.md
Normal file
20
docs/evidence/root-custody-history/README.md
Normal file
|
|
@ -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.
|
||||||
21
docs/evidence/root-custody-history/baseline.json
Normal file
21
docs/evidence/root-custody-history/baseline.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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).
|
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
|
This completes RPF-WP-0049-T04. T05 remains evidence-blocked; generic approval
|
||||||
is not pending. Old operator role configuration is not an execution receipt.
|
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.
|
||||||
|
|
|
||||||
3
scripts/root-custody-finish-attended.sh
Executable file
3
scripts/root-custody-finish-attended.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
exec python3 "$(dirname "$0")/root_custody_finish.py" "$@" >/dev/null 2>&1
|
||||||
133
scripts/root_custody_finish.py
Normal file
133
scripts/root_custody_finish.py
Normal file
|
|
@ -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']<len(encrypted):
|
||||||
|
raise ValueError('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'
|
||||||
|
status,_=network(upload+'/forgejo/'+name,'PUT',encrypted,(values['NC_WEBDAV_TOKEN'],''),{'Content-Type':'application/octet-stream','If-None-Match':'*'})
|
||||||
|
if status!=201:
|
||||||
|
raise ValueError('new_artifact_upload_failed')
|
||||||
|
status,fetched=network(HOST+'/remote.php/dav/files/Backup/railiance-backups/forgejo/'+name,'GET',auth=auth)
|
||||||
|
if status!=200 or hashlib.sha256(fetched).digest()!=hashlib.sha256(encrypted).digest():
|
||||||
|
raise ValueError('provider_readback_mismatch')
|
||||||
|
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','/dev/stdin',str(cipher)],key)
|
||||||
|
if restored!=bundle:
|
||||||
|
raise ValueError('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}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p=argparse.ArgumentParser()
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
if args.receipt.exists() or args.receipt.is_symlink():
|
||||||
|
return 1
|
||||||
|
require_attended()
|
||||||
|
if os.getenv('BAO_ADDR')!='http://127.0.0.1:18200':
|
||||||
|
return 1
|
||||||
|
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:
|
||||||
|
return 1
|
||||||
|
spec=importlib.util.spec_from_file_location('custody_validator',source)
|
||||||
|
validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator)
|
||||||
|
import fcntl
|
||||||
|
with (args.state.parent/'publisher.lock').open('a') as lock:
|
||||||
|
fcntl.flock(lock,fcntl.LOCK_EX)
|
||||||
|
state,bundle=checked_bundle(args.state,validator)
|
||||||
|
bao=Bao()
|
||||||
|
check_baseline(bao.read('platform/data/custody/net-kingdom/platform-root'),state,validator)
|
||||||
|
# Only current owner-held lane; no cached rclone config or caller token.
|
||||||
|
lane=bao.read(LANE)
|
||||||
|
operator=bao.read(OPERATOR_PATH)['data']
|
||||||
|
comparison=historical_config_comparison(Path('/home/worsch/.config/rclone/rclone.conf'),lane['data'])
|
||||||
|
if comparison['historical_share_matches_current_lane'] is True:
|
||||||
|
atomic_json(args.receipt,{'ok':False,'current_live_baseline_matches':True,**comparison,
|
||||||
|
'error':'current_backup_lane_requires_coordinated_existing_incident_recovery','recorded_at':datetime.now(timezone.utc).isoformat()})
|
||||||
|
return 1
|
||||||
|
receipt={**roundtrip(bundle,lane,operator),**comparison}
|
||||||
|
atomic_json(args.receipt,{'ok':True,'scope':'current-owner-read-and-governed-history-provider-roundtrip','record_id':RECORD_ID,'revision':state['revision'],'kv_version':state['kv_version'],'digest':state['digest'],'current_live_baseline_matches':True,'history_entries':len(json.loads(bundle)['history']),'recorded_at':datetime.now(timezone.utc).isoformat(),**receipt})
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
# Server/credential/HTTP/process diagnostics are never disclosed.
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
156
scripts/root_custody_restore_probe.py
Normal file
156
scripts/root_custody_restore_probe.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Disposable loopback Raft recovery and workstation receipt-copy proof.
|
||||||
|
|
||||||
|
No live admin login; no production backup is read; no key/token is emitted.
|
||||||
|
Temporary local-server credentials are generated and used only inside this run.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import root_grant_attended as owner
|
||||||
|
|
||||||
|
|
||||||
|
def run(args, environ, optional=False):
|
||||||
|
result = subprocess.run(["bao", *args], env=environ, capture_output=True, timeout=20)
|
||||||
|
if result.returncode and not optional:
|
||||||
|
raise RuntimeError("isolated_command_failed")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--state', type=Path, required=True)
|
||||||
|
parser.add_argument('--net-kingdom', type=Path, required=True)
|
||||||
|
parser.add_argument('--receipt', type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.receipt.exists():
|
||||||
|
raise RuntimeError('unique_receipt_required')
|
||||||
|
source = args.net_kingdom / 'tools/custody-record/validate.py'
|
||||||
|
if hashlib.sha256(source.read_bytes()).hexdigest() != '9156e399c8d92a45b817bfc3738c89843487ce57e1718ed747f804f95cc719bb':
|
||||||
|
raise RuntimeError('reviewed_validator_required')
|
||||||
|
spec = importlib.util.spec_from_file_location('custody_validator', source)
|
||||||
|
validator = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(validator)
|
||||||
|
retained = json.loads(args.state.read_text())
|
||||||
|
if retained.get('pending') or validator.validate(retained['document']) or retained['document']['entries']:
|
||||||
|
raise RuntimeError('reviewed_empty_baseline_required')
|
||||||
|
state_digest = owner.digest(retained['document'])
|
||||||
|
if state_digest != retained['digest'] or retained['kv_version'] != 1:
|
||||||
|
raise RuntimeError('baseline_digest_or_version_mismatch')
|
||||||
|
with tempfile.TemporaryDirectory(prefix='nk-root-raft-proof-') as temporary:
|
||||||
|
scratch = Path(temporary)
|
||||||
|
copied = scratch / 'receipt-holder-copy'
|
||||||
|
copied.mkdir(mode=0o700)
|
||||||
|
shutil.copy2(args.state, copied/'baseline.json')
|
||||||
|
shutil.copytree(args.state.parent/'history', copied/'history')
|
||||||
|
restored = scratch/'receipt-holder-restored'
|
||||||
|
shutil.copytree(copied, restored)
|
||||||
|
actual_state = json.loads((restored/'baseline.json').read_text())
|
||||||
|
history_file = restored/'history'/('1-'+state_digest+'.json')
|
||||||
|
if actual_state != retained or json.loads(history_file.read_text()) != retained:
|
||||||
|
raise RuntimeError('receipt_holder_restore_differs')
|
||||||
|
if ((restored/'baseline.json').stat().st_mode & 0o777) != 0o600:
|
||||||
|
raise RuntimeError('receipt_holder_permissions_differ')
|
||||||
|
raft = scratch/'raft'
|
||||||
|
raft.mkdir(mode=0o700)
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(('127.0.0.1',0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
sock.close()
|
||||||
|
if port in (8200,18200):
|
||||||
|
raise RuntimeError('reserved_live_port_refused')
|
||||||
|
config = scratch/'server.hcl'
|
||||||
|
config.write_text('disable_mlock=true\nstorage "raft" {\n path="'+str(raft)+'"\n node_id="isolated-root-recovery"\n}\nlistener "tcp" {\n address="127.0.0.1:'+str(port)+'"\n tls_disable=true\n}\napi_addr="http://127.0.0.1:'+str(port)+'"\ncluster_addr="http://127.0.0.1:'+str(port+1)+'"\n')
|
||||||
|
env = {key:value for key,value in os.environ.items() if key not in ('BAO_TOKEN','VAULT_TOKEN','BAO_ADDR','VAULT_ADDR','VAULT_NAMESPACE','BAO_NAMESPACE')}
|
||||||
|
env.update(BAO_ADDR='http://127.0.0.1:'+str(port), VAULT_ADDR='http://127.0.0.1:'+str(port), GOMAXPROCS='2')
|
||||||
|
server = subprocess.Popen(['bao','server','-config='+str(config)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||||
|
try:
|
||||||
|
for attempt in range(60):
|
||||||
|
status = run(['status','-format=json'],env,optional=True)
|
||||||
|
if status.stdout:
|
||||||
|
break
|
||||||
|
if server.poll() is not None:
|
||||||
|
raise RuntimeError('isolated_server_exited')
|
||||||
|
time.sleep(.1)
|
||||||
|
initialized = json.loads(run(['operator','init','-format=json','-key-shares=1','-key-threshold=1'],env).stdout)
|
||||||
|
seal = initialized['unseal_keys_b64'][0]
|
||||||
|
env['BAO_TOKEN'] = initialized['root_token']
|
||||||
|
# Key is passed only through captured child argv to an isolated process;
|
||||||
|
# never logged, persisted in source or emitted in evidence.
|
||||||
|
run(['operator','unseal',seal],env)
|
||||||
|
run(['secrets','enable','-path=platform','kv-v2'],env)
|
||||||
|
older = scratch/'before-custody.snap'
|
||||||
|
latest = scratch/'with-custody.snap'
|
||||||
|
run(['operator','raft','snapshot','save',str(older)],env)
|
||||||
|
# Exact owner library, on isolated transport only; env is confined
|
||||||
|
# to a short-lived child script invocation below for subprocess Bao.
|
||||||
|
class IsolatedBao(owner.Bao):
|
||||||
|
def call(self, command, payload=None, optional=False):
|
||||||
|
result = subprocess.run(['bao',*command],input=None if payload is None else json.dumps(payload).encode(),env=env,capture_output=True,timeout=15)
|
||||||
|
if result.returncode:
|
||||||
|
if optional and result.returncode==2 and result.stderr.strip()==('No value found at '+command[-1]).encode():
|
||||||
|
return None
|
||||||
|
raise owner.Refused('isolated_request_failed')
|
||||||
|
return json.loads(result.stdout) if result.stdout.strip() else {}
|
||||||
|
bao = IsolatedBao()
|
||||||
|
synthetic_state = scratch/'publisher.json'
|
||||||
|
owner.publish(bao,retained['document'],synthetic_state,validator,'isolated-copy-of-approved-empty-baseline')
|
||||||
|
owner.check_baseline(bao.read(owner.PATH),actual_state,validator)
|
||||||
|
run(['operator','raft','snapshot','save',str(latest)],env)
|
||||||
|
run(['operator','raft','snapshot','restore','-force',str(older)],env)
|
||||||
|
for attempt in range(60):
|
||||||
|
try:
|
||||||
|
old_read = bao.read(owner.PATH,optional=True)
|
||||||
|
break
|
||||||
|
except owner.Refused:
|
||||||
|
run(['operator','unseal',seal],env,optional=True)
|
||||||
|
time.sleep(.1)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('isolated_old_snapshot_unreadable')
|
||||||
|
refused = False
|
||||||
|
try:
|
||||||
|
owner.check_baseline(old_read,actual_state,validator)
|
||||||
|
except owner.Refused:
|
||||||
|
refused = True
|
||||||
|
if not refused:
|
||||||
|
raise RuntimeError('old_snapshot_not_refused')
|
||||||
|
run(['operator','raft','snapshot','restore','-force',str(latest)],env)
|
||||||
|
for attempt in range(60):
|
||||||
|
try:
|
||||||
|
latest_read = bao.read(owner.PATH)
|
||||||
|
owner.check_baseline(latest_read,actual_state,validator)
|
||||||
|
break
|
||||||
|
except (owner.Refused,KeyError,TypeError):
|
||||||
|
run(['operator','unseal',seal],env,optional=True)
|
||||||
|
time.sleep(.1)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('latest_snapshot_did_not_match')
|
||||||
|
evidence={'ok':True,'scope':'disposable-loopback-openbao-raft','production_mutated':False,'production_snapshot_used':False,'receipt_holder_scratch_restore_verified':True,'receipt_holder_backup_provider_verified':False,'old_snapshot_missing_source_refused':True,'latest_snapshot_matches_independent_baseline':True,'record_id':owner.RECORD_ID,'revision':1,'kv_version':1,'digest':state_digest,'entries_count':0,'server_version':run(['version'],env).stdout.decode().strip(),'snapshot_count':2,'temporary_artifacts_removed':True}
|
||||||
|
finally:
|
||||||
|
server.terminate()
|
||||||
|
try:
|
||||||
|
server.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
server.kill()
|
||||||
|
server.wait()
|
||||||
|
owner.atomic_json(args.receipt,evidence)
|
||||||
|
print(json.dumps(evidence,sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception:
|
||||||
|
print('{"ok":false,"error":"isolated_recovery_proof_failed"}')
|
||||||
|
sys.exit(1)
|
||||||
69
tests/test_root_custody_finish.py
Normal file
69
tests/test_root_custody_finish.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import configparser
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT=Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0,str(ROOT/'scripts'))
|
||||||
|
import root_custody_finish as finish
|
||||||
|
import root_grant_attended as owner
|
||||||
|
spec=importlib.util.spec_from_file_location('validator',ROOT.parent/'net-kingdom/tools/custody-record/validate.py')
|
||||||
|
validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator)
|
||||||
|
|
||||||
|
class FinishTests(unittest.TestCase):
|
||||||
|
def test_retained_source_copy_contains_latest_valid_history(self):
|
||||||
|
path=ROOT/'docs/evidence/root-custody-history/baseline.json'
|
||||||
|
state,bundle=finish.checked_bundle(path,validator)
|
||||||
|
self.assertEqual(state['document']['entries'],[])
|
||||||
|
self.assertIn(state,json.loads(bundle)['history'])
|
||||||
|
|
||||||
|
def test_current_historical_share_is_classified_without_values(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path=Path(tmp)/'rclone.conf'
|
||||||
|
path.write_text('[nextcloud-backup]\nurl=https://example.invalid/public.php/dav/filesdrop/TEST-SHARE-VALUE\nuser=TEST-SHARE-VALUE\n')
|
||||||
|
result=finish.historical_config_comparison(path,{'NC_WEBDAV_TOKEN':'TEST-SHARE-VALUE'})
|
||||||
|
self.assertTrue(result['historical_share_matches_current_lane'])
|
||||||
|
self.assertNotIn('TEST-SHARE-VALUE',json.dumps(result))
|
||||||
|
result=finish.historical_config_comparison(path,{'NC_WEBDAV_TOKEN':'CURRENT-DIFFERENT'})
|
||||||
|
self.assertFalse(result['historical_share_matches_current_lane'])
|
||||||
|
self.assertEqual(result['historical_credential_status'],'historical-owner-and-revocation-unverified')
|
||||||
|
|
||||||
|
def test_unverified_upload_destination_refused_before_network(self):
|
||||||
|
lane={'data':{'NC_WEBDAV_URL':'https://unapproved.invalid/private','NC_WEBDAV_TOKEN':'TEST'}}
|
||||||
|
with patch.object(finish,'request') as network:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
finish.roundtrip(b'{}',lane,{'BACKUP_USERNAME':'Backup','BACKUP_PASSWORD':'TEST'},network=network)
|
||||||
|
network.assert_not_called()
|
||||||
|
|
||||||
|
def test_actual_age_roundtrip_with_disposable_key_and_memory_provider(self):
|
||||||
|
key=subprocess.run(['age-keygen'],capture_output=True,check=True).stdout.decode()
|
||||||
|
lane={'data':{'NC_WEBDAV_URL':finish.HOST+'/public.php/dav/filesdrop/DISPOSABLE-SHARE',
|
||||||
|
'NC_WEBDAV_TOKEN':'DISPOSABLE-SHARE','AGE_PRIVATE_KEY':key},'metadata':{'version':9}}
|
||||||
|
stored={}
|
||||||
|
def memory_provider(url,method='GET',body=None,auth=None,headers=None):
|
||||||
|
if method=='PUT':
|
||||||
|
self.assertEqual(headers['If-None-Match'],'*')
|
||||||
|
stored['encrypted']=body
|
||||||
|
return 201,b''
|
||||||
|
return 200,stored['encrypted']
|
||||||
|
receipt=finish.roundtrip(b'{"nonsecret":"receipt-fixture"}',lane,
|
||||||
|
{'BACKUP_USERNAME':'Backup','BACKUP_PASSWORD':'DISPOSABLE-PASSWORD'},
|
||||||
|
network=memory_provider,get_quota=lambda auth:{'available_bytes':1048576})
|
||||||
|
self.assertTrue(receipt['decrypted_history_matches'])
|
||||||
|
self.assertNotIn(key,json.dumps(receipt))
|
||||||
|
self.assertNotIn('DISPOSABLE-SHARE',json.dumps(receipt))
|
||||||
|
|
||||||
|
def test_missing_latest_history_cannot_make_backup(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path=Path(tmp)/'baseline.json'
|
||||||
|
path.write_text((ROOT/'docs/evidence/root-custody-history/baseline.json').read_text())
|
||||||
|
(path.parent/'history').mkdir()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
finish.checked_bundle(path,validator)
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
|
|
@ -103,7 +103,7 @@ id: RPF-WP-0049-T05
|
||||||
status: wait
|
status: wait
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [RPF-WP-0049-T04]
|
depends_on: [RPF-WP-0049-T04]
|
||||||
blocking_reason: "Admission and empty publication receipts verified; workload success/denial/outage/audit correlation and independent workstation receipt-holder backup/readback plus isolated OpenBao rollback/restore evidence remain. No new generic approval gate."
|
blocking_reason: "Admission and empty publication receipts verified; workload success/denial/outage/audit correlation, current live retained-baseline restore-check and established receipt-holder backup coverage/provider readback remain. Actual disposable native-Raft rollback/restore and local holder copy/restore proof passed. No new generic approval gate."
|
||||||
state_hub_task_id: "871e3236-61c0-58a1-a307-399cd662be6b"
|
state_hub_task_id: "871e3236-61c0-58a1-a307-399cd662be6b"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -159,3 +159,15 @@ T05 remains wait for workload and independent recovery receipts; the source
|
||||||
workplan stays blocked on evidence, not another generic approval. The publisher
|
workplan stays blocked on evidence, not another generic approval. The publisher
|
||||||
state holder is already independent/off-host relative to railiance01. No new
|
state holder is already independent/off-host relative to railiance01. No new
|
||||||
service dependency is introduced solely to duplicate that holder.
|
service dependency is introduced solely to duplicate that holder.
|
||||||
|
|
||||||
|
## Native isolated recovery completed — 2026-10-03
|
||||||
|
|
||||||
|
Ran scripts/root_custody_restore_probe.py with an actual disposable loopback
|
||||||
|
OpenBao v2.5.5 Raft server, exact owner publisher/baseline guard and retained
|
||||||
|
non-secret empty document. Old snapshot missing source refused; latest snapshot
|
||||||
|
matched revision 1/KV1 digest. Scratch workstation baseline/history restore and
|
||||||
|
mode 0600 matched. Temporary daemon/snapshots/credentials removed. No production
|
||||||
|
snapshot or live admin login used. Evidence:
|
||||||
|
docs/evidence/2026-10-03-root-custody-isolated-raft-recovery.json.
|
||||||
|
Current live restore-check and established backup coverage/provider readback
|
||||||
|
remain separate evidence requirements, not new generic approvals.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue