Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 308701@bnt-lap001 Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
265 lines
14 KiB
Python
265 lines
14 KiB
Python
#!/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 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 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())
|