#!/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. `--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 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 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 expected(specs): # NAME=SHA256 pairs the drill must match; digests are public, the content stays in memory. result={} for spec in specs or []: name,sep,sha=spec.partition('=') if not sep or not ITEM.fullmatch(name) or name in result or not re.fullmatch(r'[0-9a-f]{64}',sha):raise Refused('expect') result[name]=sha return result def verify(upload,password,keyfd,receipt,client=None,expect=None): expect=expect or {} 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') for name,sha in expect.items(): if name not in found:raise Refused('expected_item_missing') if hashlib.sha256(found[name]).hexdigest()!=sha:raise Refused('item_sha256_mismatch') 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())}, 'sha256_matched':{n:expect[n] for n in sorted(expect)}, '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]) 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('--expect',action='append',metavar='NAME=SHA256',help='with --verify: item digest that must match') parser.add_argument('--receipt',type=Path,required=True) args=parser.parse_args() if args.expect and not args.verify:parser.error('--expect needs --verify') 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: expect=expected(args.expect) 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(),expect=expect) 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(): 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())