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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
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.
|
||||
|
|
|
|||
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
|
||||
priority: high
|
||||
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"
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
state holder is already independent/off-host relative to railiance01. No new
|
||||
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