NK-WP-0055-T13: kingpath-offsite helper seals break-glass material to Storage Share
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4515@bnt-lap001
Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
This commit is contained in:
tegwick 2026-10-27 23:07:22 +01:00
parent e9bc86160f
commit d7fde1f2ba
4 changed files with 309 additions and 0 deletions

View file

@ -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/'

View file

@ -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`.

View file

@ -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 0<len(specs)<=MAX_ITEMS:raise Refused('items')
result={}
for spec in specs:
name,sep,path=spec.partition('=')
if not sep or not ITEM.fullmatch(name) or name in result:raise Refused('item_name')
source=Path(os.path.expanduser(path))
try:info=source.lstat()
except OSError:raise Refused('item_missing') from None
if (not source.is_file() or source.is_symlink() or info.st_uid!=os.getuid() or info.st_nlink!=1
or info.st_mode&0o077 or not 0<info.st_size<=ITEM_LIMIT):raise Refused('item_unsafe')
result[name]=source
return result
def bundle(sources):
# Normalized in-memory tar: no owner names, times or modes from the workstation.
raw=io.BytesIO()
with tarfile.open(fileobj=raw,mode='w',format=tarfile.PAX_FORMAT) as archive:
for name,source in sorted(sources.items()):
data=source.read_bytes()
entry=tarfile.TarInfo(name);entry.size=len(data);entry.mode=0o600;entry.mtime=0
archive.addfile(entry,io.BytesIO(data))
return raw.getvalue()
def seal(plaintext,target,recipient=RECIPIENT):
if not re.fullmatch(r'age1[0-9a-z]{58}',recipient):raise Refused('recipient')
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
with os.fdopen(fd,'wb') as out:
done=subprocess.run(['age','-r',recipient],input=plaintext,stdout=out,stderr=subprocess.DEVNULL,check=False)
out.flush();os.fsync(out.fileno())
with target.open('rb') as sealed:
if done.returncode or sealed.read(22)!=b'age-encryption.org/v1\n':raise Refused('seal')
return offsite.digest(target),target.stat().st_size
def auth(password):
if not password or any(c in password for c in '\r\n\x00') or len(password)>256: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())

View file

@ -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