diff --git a/tests/test_kingpath_offsite.py b/tests/test_kingpath_offsite.py new file mode 100644 index 0000000..f4f690c --- /dev/null +++ b/tests/test_kingpath_offsite.py @@ -0,0 +1,127 @@ +"""Break-glass bundle sealing and Storage Share publication on contained fixtures.""" +from datetime import datetime,timezone +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tarfile +import urllib.error +import pytest + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location('kingpath_offsite',ROOT/'tools/bootstrap-recovery/kingpath-offsite.py') +k=importlib.util.module_from_spec(spec);spec.loader.exec_module(k) +NOW=datetime(2026,10,27,22,0,tzinfo=timezone.utc) + + +class Response(io.BytesIO): + def __init__(self,url,data=b'',status=200,etag='"v1"'): + super().__init__(data);self.url=url;self.status=status + self.headers={'ETag':etag,'Content-Length':str(len(data))} + def __enter__(self):return self + def __exit__(self,*a):self.close() + + +class Share: + def __init__(self,folder_exists=False): + self.objects={};self.folder=folder_exists;self.calls=[];self.tamper=False;self.collide=False + def open(self,request,timeout=None): + method=request.get_method();url=request.full_url;self.calls.append((method,url)) + assert request.headers['Authorization'].startswith('Basic ') + if method=='MKCOL': + if self.folder:raise urllib.error.HTTPError(url,405,'exists',{},None) + self.folder=True;return Response(url,status=201) + if method=='PUT': + assert request.headers['If-none-match']=='*' + self.objects[url]=request.data.read();return Response(url,status=201) + if self.collide and method=='HEAD':return _head(url,b'other') + if url not in self.objects:raise urllib.error.HTTPError(url,404,'missing',{},None) + data=self.objects[url] + if method=='HEAD':return _head(url,data) + if self.tamper:data=data[:-1]+bytes([data[-1]^1]) + return Response(url,data) + + +def _head(url,data): + r=Response(url,b'');r.headers['Content-Length']=str(len(data));return r + + +@pytest.fixture +def key(tmp_path): + identity=tmp_path/'fixture.key' + subprocess.run(['age-keygen','-o',str(identity)],check=True,capture_output=True) + recipient=subprocess.run(['age-keygen','-y',str(identity)],check=True,capture_output=True,text=True).stdout.strip() + return identity,recipient + + +def private(tmp_path,name,data,mode=0o600): + path=tmp_path/name;path.write_bytes(data);path.chmod(mode);return path + + +def test_seals_uploads_and_reads_back(tmp_path,key): + identity,recipient=key;share=Share() + a=private(tmp_path,'kc',b'synthetic-kubeconfig');b=private(tmp_path,'ssh',b'synthetic-ssh-key') + receipt=tmp_path/'receipt.json' + record=k.run([f'kubeconfig={a}',f'tunnel-ssh-key={b}'],'synthetic-app-password',receipt,share,recipient,NOW) + assert record['status']=='readback_verified' and record['items']=={'kubeconfig':20,'tunnel-ssh-key':17} + assert record['artifact'].startswith('net-kingdom-kingpath-20261027T220000Z-') + assert oct(receipt.stat().st_mode&0o777)=='0o600' + text=receipt.read_text();assert 'synthetic' not in text + (stored,)=share.objects.values();assert stored.startswith(b'age-encryption.org/v1\n') and b'synthetic' not in stored + plain=subprocess.run(['age','-d','-i',str(identity)],input=stored,capture_output=True,check=True).stdout + with tarfile.open(fileobj=io.BytesIO(plain)) as archive: + assert {m.name:(archive.extractfile(m).read(),m.mtime,m.mode) for m in archive}=={ + 'kubeconfig':(b'synthetic-kubeconfig',0,0o600),'tunnel-ssh-key':(b'synthetic-ssh-key',0,0o600)} + assert [c[0] for c in share.calls]==['MKCOL','HEAD','PUT','HEAD','GET'] + + +def test_existing_folder_is_accepted(tmp_path,key): + share=Share(folder_exists=True) + k.run([f'kubeconfig={private(tmp_path,"kc",b"x")}'],'pw',tmp_path/'r.json',share,key[1],NOW) + assert len(share.objects)==1 + + +@pytest.mark.parametrize('mode',[0o640,0o604,0o644]) +def test_refuses_group_or_world_readable_input(tmp_path,key,mode): + with pytest.raises(k.Refused): + k.run([f'kubeconfig={private(tmp_path,"kc",b"x",mode)}'],'pw',tmp_path/'r.json',Share(),key[1],NOW) + + +def test_refuses_symlink_bad_name_duplicate_and_empty(tmp_path,key): + real=private(tmp_path,'kc',b'x');link=tmp_path/'link';link.symlink_to(real) + for specs in ([f'kubeconfig={link}'],[f'Bad_Name={real}'],[f'a={real}',f'a={real}'], + [f'a={private(tmp_path,"empty",b"")}'],[],[f'a={tmp_path/"missing"}']): + with pytest.raises(k.Refused):k.run(specs,'pw',tmp_path/'r.json',Share(),key[1],NOW) + + +def test_refuses_existing_receipt_before_any_provider_call(tmp_path,key): + share=Share();receipt=private(tmp_path,'r.json',b'{}') + with pytest.raises(k.Refused):k.run([f'a={private(tmp_path,"kc",b"x")}'],'pw',receipt,share,key[1],NOW) + assert share.calls==[] + + +def test_refuses_tampered_readback_and_writes_no_receipt(tmp_path,key): + share=Share();share.tamper=True;receipt=tmp_path/'r.json' + with pytest.raises(k.Refused):k.run([f'a={private(tmp_path,"kc",b"x")}'],'pw',receipt,share,key[1],NOW) + assert not receipt.exists() + + +def test_refuses_overwriting_an_existing_object(tmp_path,key): + share=Share();share.collide=True + with pytest.raises(k.Refused):k.run([f'a={private(tmp_path,"kc",b"x")}'],'pw',tmp_path/'r.json',share,key[1],NOW) + assert 'PUT' not in [c[0] for c in share.calls] + + +def test_refuses_bad_credential_and_recipient(tmp_path,key): + item=private(tmp_path,'kc',b'x') + for pw in ('','a\nb'): + with pytest.raises(k.Refused):k.run([f'a={item}'],pw,tmp_path/'r.json',Share(),key[1],NOW) + with pytest.raises(k.Refused):k.run([f'a={item}'],'pw',tmp_path/'r.json',Share(),'not-a-recipient',NOW) + + +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/' diff --git a/tools/bootstrap-recovery/README.md b/tools/bootstrap-recovery/README.md index 6756f25..10f7649 100644 --- a/tools/bootstrap-recovery/README.md +++ b/tools/bootstrap-recovery/README.md @@ -115,3 +115,15 @@ primary cluster and current deployment/PVC/reference bindings using metadata only. It refuses replacement resources, unobserved/unready deployments and new unreviewed references. It has no Secret-data, pod-exec, mutation or upload route. Its receipt says `prepared_not_admitted`; it cannot prove a backup exists. + +`kingpath-offsite.py --item NAME=PATH ... --receipt PATH` (NK-WP-0055-T13) seals +break-glass access material that otherwise lives only on the founder workstation, +such as the attended admin kubeconfig and the static tunnel key, to the registered +`railiance-backup` recipient. It uploads the result under a new name to the +Storage Share `Backup` account (`railiance-backups/net-kingdom-kingpath/`) and +reads it back with a hash check. Run it in your own terminal: it refuses unsafe +inputs (symlinks, group/world-readable, foreign-owned) before prompting for the +app password. Plaintext is packed in memory and never written to disk. The +receipt (0600) holds item names, sizes and the ciphertext digest, never values. +Recovery needs only the age key and the Storage Share password (crisis custody +list items 1 and 2). Contained tests: `tests/test_kingpath_offsite.py`. diff --git a/tools/bootstrap-recovery/kingpath-offsite.py b/tools/bootstrap-recovery/kingpath-offsite.py new file mode 100644 index 0000000..4347d07 --- /dev/null +++ b/tools/bootstrap-recovery/kingpath-offsite.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Seal break-glass access material to the backup recipient and publish it to Storage Share. + +NK-WP-0055-T13. The founder runs this in their own terminal. Named private files +(admin kubeconfig, static tunnel key, ops bundle) are packed in memory, encrypted +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. +""" +import argparse +import base64 +from datetime import datetime,timezone +import getpass +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tarfile +import tempfile +import urllib.error +import urllib.request +import warnings + +HERE=Path(__file__).resolve().parent +spec=importlib.util.spec_from_file_location('identity_offsite',HERE/'identity-offsite.py') +offsite=importlib.util.module_from_spec(spec);spec.loader.exec_module(offsite) +RECIPIENT=json.loads((HERE/'identity-backup-profile.json').read_text())['public_recipient'] +FOLDER='railiance-backups/net-kingdom-kingpath/' +ACCOUNT_BASE=offsite.HOST+'/remote.php/dav/files/Backup/'+FOLDER +ITEM=re.compile(r'[a-z0-9][a-z0-9-]{0,39}\Z') +ITEM_LIMIT=64*1024*1024 +MAX_ITEMS=8 + + +class Refused(Exception):pass + + +def items(specs): + # Each item is a private, regular, single-link file owned by the caller. + if not 0256:raise Refused('credential') + return {'Authorization':'Basic '+base64.b64encode(('Backup:'+password).encode()).decode(), + 'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'} + + +def ensure_folder(client,h): + try: + with client.open(urllib.request.Request(ACCOUNT_BASE,method='MKCOL',headers=h),timeout=30) as response: + if response.status!=201:raise Refused('folder') + except urllib.error.HTTPError as error: + if error.code!=405:raise Refused('folder') from None # 405: the folder already exists + + +def check(response,url,size): + value=response.headers.get('ETag','') + if (response.status!=200 or response.url!=url or response.headers.get('Content-Length')!=str(size) + or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',value)):raise Refused('readback') + return value + + +def publish(client,password,sealed,sha,size,stamp): + name=f'net-kingdom-kingpath-{stamp}-{sha[:12]}.tar.age' + url=ACCOUNT_BASE+name;h=auth(password) + ensure_folder(client,h) + try: + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30):raise Refused('exists') + except urllib.error.HTTPError as error: + if error.code!=404:raise Refused('exists') from None + put=dict(h);put.update({'If-None-Match':'*','Content-Type':'application/octet-stream','Content-Length':str(size)}) + # An unknown upload outcome is never retried under this name automatically. + with sealed.open('rb') as stream: + with client.open(urllib.request.Request(url,data=stream,method='PUT',headers=put),timeout=600) as response: + if response.status!=201 or response.url!=url:raise Refused('upload') + with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=check(response,url,size) + get=dict(h);get['If-Match']=version;digest=hashlib.sha256();count=0 + with client.open(urllib.request.Request(url,headers=get),timeout=180) as response: + if check(response,url,size)!=version:raise Refused('readback') + while block:=response.read(1024*1024): + count+=len(block) + if count>size:raise Refused('readback') + digest.update(block) + if count!=size or digest.hexdigest()!=sha:raise Refused('readback') + return name,version + + +def run(specs,password,receipt,client=None,recipient=RECIPIENT,now=None): + if receipt.exists() or receipt.is_symlink():raise Refused('receipt_exists') + sources=items(specs) + stamp=(now or datetime.now(timezone.utc)).strftime('%Y%m%dT%H%M%SZ') + with tempfile.TemporaryDirectory(prefix='.kingpath-') as temporary: + sealed=Path(temporary)/'bundle.tar.age' + sha,size=seal(bundle(sources),sealed,recipient) + name,version=publish(client or offsite.opener(),password,sealed,sha,size,stamp) + record={'schema':'netkingdom.kingpath-offsite.v1','status':'readback_verified', + 'observed_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share', + 'folder':'Backup/'+FOLDER,'artifact':name,'etag':version,'recipient':recipient, + 'ciphertext_sha256':sha,'ciphertext_bytes':size, + 'items':{n:s.stat().st_size for n,s in sorted(sources.items())}, + 'plaintext_written_to_disk':False,'local_ciphertext_removed':True, + '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) + 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 + try: + sources=items(args.item) # refuse unsafe input before asking for the credential + with warnings.catch_warnings(): + warnings.simplefilter('error',getpass.GetPassWarning) + password=getpass.getpass('Storage Share Backup app password: ') + run(args.item,password,args.receipt.expanduser()) + except (Refused,getpass.GetPassWarning) as error: + print(f'refused: {error}',file=sys.stderr);return 1 + except (urllib.error.URLError,OSError): + print('refused: provider or local I/O failure (no retry under the same name)',file=sys.stderr);return 1 + print(f'OK: {len(sources)} items sealed, uploaded and read back; receipt {args.receipt}') + return 0 + + +if __name__=='__main__':sys.exit(main()) diff --git a/workplans/NK-WP-0055-security-platform-production-readiness.md b/workplans/NK-WP-0055-security-platform-production-readiness.md index b6a0243..739b787 100644 --- a/workplans/NK-WP-0055-security-platform-production-readiness.md +++ b/workplans/NK-WP-0055-security-platform-production-readiness.md @@ -304,6 +304,8 @@ KeyCape/Authelia down is recorded and drilled by the founder; and use produces a 2026-10-27 documentation pass: the crisis custody list (items 3, 7, 9) and the disaster recovery guide (new section "Reaching the platform when SSO and OpenBao are down") now record what is known. Findings: ops-warden SSH certificates do **not** work with OpenBao down (warden backend is OpenBao); the working path today is the static-key tunnel `k3s-api-railiance01` as `tegwick` plus the admin kubeconfig client certificate, workstation-only with no second copy; the OpenBao `generate-root` break-glass ceremony is undocumented; provider console access, the ops bundle location (item 8) and offline/mirror status of the password safe are `TO CONFIRM`. 2026-10-27 founder answers: unseal shares and `pi-admin` are in the founder's replicated browser password safe (unseal shares under the `kingpath.credentials` pseudo-URL entry); console and rescue access to railiance01 through the Host Europe provider interface; the ops bundle and the cluster break-glass material (static tunnel key, admin kubeconfig) are to be kept age-encrypted on the Storage Share `Backup` account so they survive workstation loss. Next: a guarded upload helper with readback (founder-run), the `generate-root` ceremony with railiance-platform, and the king-path drill. Still open: whether the initial OpenBao root token was kept or revoked. + +2026-10-27: the founder holds the initial OpenBao root token offline; whether it is still valid is unconfirmed (no rotation was recorded). Built `tools/bootstrap-recovery/kingpath-offsite.py` with 11 contained tests: it seals named private files in memory to the `railiance-backup` recipient, uploads to `Backup/railiance-backups/net-kingdom-kingpath/` without overwriting, and reads back with a hash check. Founder-run. No ops bundle exists on the workstation (`make creds-bundle` packs only greenfield `creds-generate` output); the daily identity backup already carries current service secrets, so item 8 is proposed for retirement as superseded. ## Policy import pipeline and platform/tenant package split ```task