NK-WP-0055-T13: kingpath-offsite --verify restores and checks the break-glass copy
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:47:21 +01:00
parent fd64fc8478
commit 317d578193
3 changed files with 134 additions and 1 deletions

View file

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

View file

@ -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():

View file

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