net-kingdom/tools/bootstrap-recovery/identity-offsite.py
tegwick 994d11c869
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
feat(recovery): prepare full identity backup and bootstrap handoff
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-16 01:37:11 +02:00

124 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Folder-scoped Storage Share ciphertext publication and verified readback.
Runtime gets only a dedicated read/create share token. Account administration,
deletion and age private keys have no route in this publisher.
"""
import base64
from datetime import datetime,timezone
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
HOST='https://nx4069.your-storageshare.de'
BASE=HOST+'/public.php/webdav/'
NAME=re.compile(r'net-kingdom-identity-\d{8}T\d{6}Z-[a-f0-9]{12}\.tar\.age\Z')
LIMIT=512*1024*1024
HEADROOM=2*1024**3
class Refused(Exception):pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self,*args,**kwargs):raise Refused()
def opener():return urllib.request.build_opener(urllib.request.ProxyHandler({}),NoRedirect())
def headers(token):
if not isinstance(token,str) or not re.fullmatch('[A-Za-z0-9]{10,128}',token):raise Refused()
return {'Authorization':'Basic '+base64.b64encode((token+':').encode()).decode(),
'X-Requested-With':'XMLHttpRequest','Accept-Encoding':'identity'}
def digest(path):
with path.open('rb') as source:return hashlib.file_digest(source,'sha256').hexdigest()
def quota(client,token):
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:quota-available-bytes/></d:prop></d:propfind>'
h=headers(token);h.update(Depth='0');h['Content-Type']='application/xml'
with client.open(urllib.request.Request(BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response:
if response.status!=207 or response.url!=BASE:raise Refused()
raw=response.read(1024*1024+1)
if len(raw)>1024*1024:raise Refused()
amount=ET.fromstring(raw).findtext('.//{DAV:}quota-available-bytes') or ''
if not amount.isdigit():raise Refused()
return int(amount)
def etag(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()
return value
def readback(client,token,name,size,expected,target):
valid=NAME.fullmatch(name) or (name.endswith('.receipt.json') and NAME.fullmatch(name.removesuffix('.receipt.json')) and size<=65536)
if not valid or not 0<size<=LIMIT:raise Refused()
url=BASE+name;h=headers(token)
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:version=etag(response,url,size)
h['If-Match']=version;count=0;sha=hashlib.sha256()
with client.open(urllib.request.Request(url,headers=h),timeout=180) as response:
if etag(response,url,size)!=version:raise Refused()
fd=os.open(target,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600)
with os.fdopen(fd,'wb') as out:
while block:=response.read(1024*1024):
count+=len(block)
if count>size:raise Refused()
sha.update(block);out.write(block)
out.flush();os.fsync(out.fileno())
if count!=size or sha.hexdigest()!=expected:raise Refused()
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30) as response:
if etag(response,url,size)!=version:raise Refused()
return version
def publish(client,token,source,capture,target):
if (not NAME.fullmatch(source.name) or source.is_symlink() or source.stat().st_mode&0o077
or source.stat().st_uid!=os.getuid() or source.stat().st_nlink!=1 or not source.is_file()
or source.stat().st_size>LIMIT or capture.get('status')!='encrypted_bundle_created'
or capture.get('helper_cleanup_verified') is not True or capture.get('temporary_plaintext_cleanup_verified') is not True
or digest(source)!=capture.get('ciphertext_sha256') or source.stat().st_size!=capture.get('ciphertext_bytes')):raise Refused()
with source.open('rb') as stream:
if stream.read(22)!=b'age-encryption.org/v1\n':raise Refused()
available=quota(client,token)
if available<source.stat().st_size+HEADROOM:raise Refused()
url=BASE+source.name;h=headers(token)
try:
with client.open(urllib.request.Request(url,method='HEAD',headers=h),timeout=30):raise Refused()
except urllib.error.HTTPError as error:
if error.code!=404:raise Refused() from None
h.update({'If-None-Match':'*','Content-Type':'application/octet-stream','Content-Length':str(source.stat().st_size)})
# An unknown upload outcome is never retried under this name automatically.
with source.open('rb') as stream:
with client.open(urllib.request.Request(url,data=stream,method='PUT',headers=h),timeout=600) as response:
if response.status!=201 or response.url!=url:raise Refused()
version=readback(client,token,source.name,source.stat().st_size,capture['ciphertext_sha256'],target)
index={'schema':'netkingdom.identity-offsite-index.v1','status':'readback_verified',
'artifact':source.name,'ciphertext_sha256':capture['ciphertext_sha256'],
'ciphertext_bytes':source.stat().st_size,'captured_at':capture['finished_at']}
raw=json.dumps(index,sort_keys=True).encode();index_name=source.name+'.receipt.json';index_url=BASE+index_name
h=headers(token);h.update({'If-None-Match':'*','Content-Type':'application/json','Content-Length':str(len(raw))})
with client.open(urllib.request.Request(index_url,data=raw,method='PUT',headers=h),timeout=30) as response:
if response.status!=201 or response.url!=index_url:raise Refused()
with tempfile.TemporaryDirectory(prefix='.identity-index-',dir=target.parent) as temporary:
readback(client,token,index_name,len(raw),hashlib.sha256(raw).hexdigest(),Path(temporary)/'index.json')
return {'schema':'netkingdom.identity-offsite.v1','status':'readback_verified',
'observed_at':datetime.now(timezone.utc).isoformat(),'provider':'Storage Share',
'folder':'Backup/railiance-backups/net-kingdom-identity','artifact':source.name,
'ciphertext_sha256':capture['ciphertext_sha256'],'ciphertext_bytes':source.stat().st_size,
'fetched_path':str(target),'etag':version,'quota_available_before':available,
'new_object_created':True,'downloaded_digest_verified':True,
'verified_index_published':True,'index_name':index_name,
'age_private_key_used':False,'secret_values_exported':False,'retained_objects_deleted':False,
'native_identity_recovery_proven':False}