NK-WP-0055-T13: kingpath-offsite helper seals break-glass material to Storage Share
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:
parent
e9bc86160f
commit
d7fde1f2ba
4 changed files with 309 additions and 0 deletions
127
tests/test_kingpath_offsite.py
Normal file
127
tests/test_kingpath_offsite.py
Normal 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/'
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
168
tools/bootstrap-recovery/kingpath-offsite.py
Normal file
168
tools/bootstrap-recovery/kingpath-offsite.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue