From 317d578193061a73281eb91da2863c75b09d0e31 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 27 Oct 2026 23:47:21 +0100 Subject: [PATCH] NK-WP-0055-T13: kingpath-offsite --verify restores and checks the break-glass copy Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 4515@bnt-lap001 Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354 --- tests/test_kingpath_offsite.py | 52 ++++++++++++ tools/bootstrap-recovery/kingpath-offsite.py | 81 ++++++++++++++++++- ...-security-platform-production-readiness.md | 2 + 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/tests/test_kingpath_offsite.py b/tests/test_kingpath_offsite.py index f4f690c..726b3c7 100644 --- a/tests/test_kingpath_offsite.py +++ b/tests/test_kingpath_offsite.py @@ -125,3 +125,55 @@ def test_production_recipient_is_the_registered_backup_recipient(): profile=json.loads((ROOT/'tools/bootstrap-recovery/identity-backup-profile.json').read_text()) assert k.RECIPIENT==profile['public_recipient'] assert k.ACCOUNT_BASE=='https://nx4069.your-storageshare.de/remote.php/dav/files/Backup/railiance-backups/net-kingdom-kingpath/' + + +KUBECONFIG=b'apiVersion: v1\nclusters: [{name: c, cluster: {server: "https://127.0.0.1:1"}}]\nusers: [{name: u, user: {}}]\n' +SSHKEY=b'-----BEGIN OPENSSH PRIVATE KEY-----\nsynthetic\n-----END OPENSSH PRIVATE KEY-----\n' + + +def uploaded(tmp_path,key,share): + items=[f'admin-kubeconfig={private(tmp_path,"kc",KUBECONFIG)}',f'tunnel-ssh-key={private(tmp_path,"ssh",SSHKEY)}'] + return k.run(items,'pw',tmp_path/'upload.json',share,key[1],NOW) + + +def keyfd(path):return os.open(path,os.O_RDONLY) + + +def test_verify_fetches_decrypts_in_memory_and_checks_shapes(tmp_path,key): + share=Share();upload=uploaded(tmp_path,key,share);fd=keyfd(key[0]) + try:record=k.verify(upload,'pw',fd,tmp_path/'verify.json',share) + finally:os.close(fd) + assert record['status']=='verified' and record['unrelated_key_refused'] and record['custody_key_matches_recipient'] + assert record['items']=={'admin-kubeconfig':{'bytes':len(KUBECONFIG),'shape':'kubeconfig'}, + 'tunnel-ssh-key':{'bytes':len(SSHKEY),'shape':'ssh-private-key'}} + text=(tmp_path/'verify.json').read_text();assert 'synthetic' not in text and '127.0.0.1' not in text + + +def test_verify_refuses_a_custody_key_for_another_recipient(tmp_path,key): + share=Share();upload=uploaded(tmp_path,key,share) + other=tmp_path/'other.key';subprocess.run(['age-keygen','-o',str(other)],check=True,capture_output=True) + fd=keyfd(other) + try: + with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share) + finally:os.close(fd) + assert [c[0] for c in share.calls].count('GET')==1 # only the upload readback; no fetch with the wrong key + + +@pytest.mark.parametrize('change',['etag','sha','items','artifact']) +def test_verify_refuses_a_changed_record_or_object(tmp_path,key,change): + share=Share();upload=uploaded(tmp_path,key,share);fd=keyfd(key[0]) + if change=='etag':upload['etag']='"other"' + if change=='sha':upload['ciphertext_sha256']='0'*64 + if change=='items':upload['items']={'admin-kubeconfig':1} + if change=='artifact':upload['artifact']='../escape.tar.age' + try: + with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share) + finally:os.close(fd) + assert not (tmp_path/'verify.json').exists() + + +def test_verify_refuses_tampered_ciphertext(tmp_path,key): + share=Share();upload=uploaded(tmp_path,key,share);share.tamper=True;fd=keyfd(key[0]) + try: + with pytest.raises(k.Refused):k.verify(upload,'pw',fd,tmp_path/'verify.json',share) + finally:os.close(fd) diff --git a/tools/bootstrap-recovery/kingpath-offsite.py b/tools/bootstrap-recovery/kingpath-offsite.py index 4347d07..ed7ad9f 100644 --- a/tools/bootstrap-recovery/kingpath-offsite.py +++ b/tools/bootstrap-recovery/kingpath-offsite.py @@ -6,6 +6,12 @@ NK-WP-0055-T13. The founder runs this in their own terminal. Named private files to the registered public backup recipient, uploaded under a new name to the `Backup` account and read back with a hash check. Plaintext never reaches disk; the receipt holds names, sizes and the ciphertext digest only. + +`--verify UPLOAD_RECEIPT` is the drill: it fetches that artifact back from +Storage Share, checks ETag, size and digest against the upload receipt, refuses +an unrelated key, decrypts in memory with the custody key from +`sops-custody-unlock.sh --sealed-fd` and checks that every item is present and +intact. Nothing is decrypted to disk and no item value is printed. """ import argparse import base64 @@ -144,13 +150,86 @@ def run(specs,password,receipt,client=None,recipient=RECIPIENT,now=None): return record +def fetch(client,password,upload): + url=ACCOUNT_BASE+upload['artifact'];h=auth(password);size=upload['ciphertext_bytes'] + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=check(response,url,size) + if version!=upload['etag']:raise Refused('etag_changed') + get=dict(h);get['If-Match']=version + with client.open(urllib.request.Request(url,headers=get),timeout=180) as response: + if check(response,url,size)!=version:raise Refused('readback') + data=response.read(size+1) + if len(data)!=size or hashlib.sha256(data).hexdigest()!=upload['ciphertext_sha256']:raise Refused('readback') + return data + + +def kind(name,data): + # Shape only: proves the item is usable without recording any of its content. + if name.endswith('kubeconfig'): + import yaml + config=yaml.safe_load(data) + if not isinstance(config,dict) or not config.get('clusters') or not config.get('users'):raise Refused('item_shape') + return 'kubeconfig' + if name.endswith('ssh-key'): + first=data.split(b'\n',1)[0] + if not re.fullmatch(rb'-----BEGIN (OPENSSH|RSA|EC) PRIVATE KEY-----',first):raise Refused('item_shape') + return 'ssh-private-key' + return 'opaque' + + +def verify(upload,password,keyfd,receipt,client=None): + if receipt.exists() or receipt.is_symlink():raise Refused('receipt_exists') + if (upload.get('schema')!='netkingdom.kingpath-offsite.v1' or upload.get('status')!='readback_verified' + or not re.fullmatch(r'net-kingdom-kingpath-\d{8}T\d{6}Z-[0-9a-f]{12}\.tar\.age',upload.get('artifact',''))):raise Refused('upload_receipt') + derived=subprocess.run(['age-keygen','-y',f'/proc/self/fd/{keyfd}'],pass_fds=(keyfd,),capture_output=True,timeout=10) + if derived.returncode or derived.stdout.decode().strip()!=upload['recipient']:raise Refused('wrong_custody_key') + data=fetch(client or offsite.opener(),password,upload) + with tempfile.TemporaryDirectory(prefix='.kingpath-verify-') as temporary: + wrong=Path(temporary)/'unrelated.key' + subprocess.run(['age-keygen','-o',str(wrong)],capture_output=True,check=True) + if subprocess.run(['age','-d','-i',str(wrong)],input=data,capture_output=True).returncode==0:raise Refused('unrelated_key_accepted') + done=subprocess.run(['age','-d','-i',f'/proc/self/fd/{keyfd}'],input=data,pass_fds=(keyfd,),capture_output=True,timeout=60) + if done.returncode:raise Refused('decrypt') + found={} + with tarfile.open(fileobj=io.BytesIO(done.stdout)) as archive: + for member in archive: + if not member.isfile() or member.name in found:raise Refused('archive') + found[member.name]=archive.extractfile(member).read() + if {n:len(d) for n,d in found.items()}!=upload['items']:raise Refused('items_changed') + record={'schema':'netkingdom.kingpath-verify.v1','status':'verified', + 'observed_at':datetime.now(timezone.utc).isoformat(),'artifact':upload['artifact'],'etag':upload['etag'], + 'ciphertext_sha256':upload['ciphertext_sha256'],'unrelated_key_refused':True,'custody_key_matches_recipient':True, + 'items':{n:{'bytes':len(d),'shape':kind(n,d)} for n,d in sorted(found.items())}, + 'plaintext_written_to_disk':False,'secret_values_exported':False,'openbao_contacted':False,'sso_contacted':False} + fd=os.open(receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600) + with os.fdopen(fd,'w') as out:json.dump(record,out,indent=2,sort_keys=True);out.write('\n') + return record + + def main(): parser=argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument('--item',action='append',default=[],metavar='NAME=PATH',required=True) + mode=parser.add_mutually_exclusive_group(required=True) + mode.add_argument('--item',action='append',metavar='NAME=PATH') + mode.add_argument('--verify',type=Path,metavar='UPLOAD_RECEIPT') parser.add_argument('--receipt',type=Path,required=True) args=parser.parse_args() if not sys.stdin.isatty(): print('refused: run in your own terminal',file=sys.stderr);return 2 + if args.verify: + keyfd=os.environ.get('NETKINGDOM_CUSTODY_AGE_KEY_FD','') + if not keyfd.isdigit(): + print('refused: run under sops-custody-unlock.sh --sealed-fd',file=sys.stderr);return 2 + try: + upload=json.loads(args.verify.expanduser().read_text()) + with warnings.catch_warnings(): + warnings.simplefilter('error',getpass.GetPassWarning) + password=getpass.getpass('Storage Share Backup app password: ') + record=verify(upload,password,int(keyfd),args.receipt.expanduser()) + except (Refused,getpass.GetPassWarning,ValueError,KeyError) as error: + print(f'refused: {error}',file=sys.stderr);return 1 + except (urllib.error.URLError,OSError): + print('refused: provider or local I/O failure',file=sys.stderr);return 1 + print(f'OK: {len(record["items"])} items fetched, decrypted in memory and intact; receipt {args.receipt}') + return 0 try: sources=items(args.item) # refuse unsafe input before asking for the credential with warnings.catch_warnings(): diff --git a/workplans/NK-WP-0055-security-platform-production-readiness.md b/workplans/NK-WP-0055-security-platform-production-readiness.md index 3fdf9f9..b3a943f 100644 --- a/workplans/NK-WP-0055-security-platform-production-readiness.md +++ b/workplans/NK-WP-0055-security-platform-production-readiness.md @@ -318,6 +318,8 @@ root token against an isolated restored OpenBao (never production); use produces 2026-10-27: railiance-platform wrote "Break-glass: OpenBao with SSO down" in `docs/openbao.md` (`8016f56`, pushed): access via the static-key tunnel and admin kubeconfig, unseal at hidden prompts, value-free held-token check, the `generate-root` ceremony through the API (server OTP, shares at hidden prompts, in-memory decode, nothing in argv/env/history), revoke-and-confirm, and a post-event review record. Drill: RPF-WP-0064-T10 on an isolated restore, including the held root-token check. **Finding:** OpenBao 2.5.4 disables unauthenticated `generate-root` by default and the production listener (`rapp-openbao/helm/openbao-values.yaml`) does not enable it, so the ceremony cannot run on production without an existing token. rapp-openbao must choose (a) an incident-time listener edit plus restart and re-unseal, or (b) a standing loopback-only listener reachable only through `kubectl port-forward`/`exec`. 2026-10-27 founder decision (hub decision `732b5c96`): option (b), a standing loopback-only OpenBao listener with `generate-root` enabled, reachable only through `kubectl port-forward`/`exec` with the admin kubeconfig. Owner rapp-openbao prepares the change for founder review and rollout. + +2026-10-27: `kingpath-offsite.py --verify UPLOAD_RECEIPT` added for the drill (7 more contained tests, 18 total): run under `sops-custody-unlock.sh --sealed-fd`, it checks the custody key against the recorded recipient, fetches the artifact with ETag/size/digest checks, refuses an unrelated key, decrypts in memory and checks each item's size and shape (kubeconfig with clusters/users, SSH private key header), writing a value-free receipt. The OpenBao part of the drill is RPF-WP-0064-T10 (drill mode being built). ## Policy import pipeline and platform/tenant package split ```task