net-kingdom/tools/bootstrap-recovery/identity-retention.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

106 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Separate folder-scoped read/delete executor for verified new identity backups.
Unknown/unindexed files are never eligible. The recovered point, newest point,
seven daily and two weekly points remain protected. No grant or account login.
"""
from datetime import datetime,timezone
import hashlib
import importlib.util
import json
from pathlib import Path
import re
import tempfile
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
spec=importlib.util.spec_from_file_location('offsite',Path(__file__).with_name('identity-offsite.py'))
offsite=importlib.util.module_from_spec(spec);spec.loader.exec_module(offsite)
def listing(client,token):
h=offsite.headers(token);h.update({'Depth':'1','Content-Type':'application/xml'})
body=b'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getetag/></d:prop></d:propfind>'
with client.open(urllib.request.Request(offsite.BASE,data=body,method='PROPFIND',headers=h),timeout=30) as response:
if response.status!=207 or response.url!=offsite.BASE:raise offsite.Refused()
raw=response.read(4*1024*1024+1)
if len(raw)>4*1024*1024:raise offsite.Refused()
result={}
for response in ET.fromstring(raw).findall('{DAV:}response'):
href=urllib.parse.urlsplit(response.findtext('{DAV:}href') or '')
if href.netloc and href.netloc!=urllib.parse.urlsplit(offsite.HOST).netloc:raise offsite.Refused()
name=urllib.parse.unquote(href.path.rstrip('/').rsplit('/',1)[-1])
for row in response.findall('{DAV:}propstat'):
if ' 200 ' not in (row.findtext('{DAV:}status') or ''):continue
p=row.find('{DAV:}prop')
if p is None or p.find('{DAV:}resourcetype/{DAV:}collection') is not None:continue
is_index=name.endswith('.receipt.json') and offsite.NAME.fullmatch(name.removesuffix('.receipt.json'))
if not offsite.NAME.fullmatch(name) and not is_index:continue
size=p.findtext('{DAV:}getcontentlength') or '';version=p.findtext('{DAV:}getetag') or ''
if not size.isdigit() or not re.fullmatch(r'"[^"\x00-\x20]{1,180}"',version) or name in result:raise offsite.Refused()
result[name]={'bytes':int(size),'etag':version}
if len(result)>10000:raise offsite.Refused()
return result
def generations(client,token,objects):
result=[]
for name,meta in objects.items():
if not offsite.NAME.fullmatch(name) or name+'.receipt.json' not in objects:continue
index=objects[name+'.receipt.json']
if not 0<index['bytes']<=65536:raise offsite.Refused()
url=offsite.BASE+name+'.receipt.json';h=offsite.headers(token);h['If-Match']=index['etag']
with client.open(urllib.request.Request(url,headers=h),timeout=30) as response:
if offsite.etag(response,url,index['bytes'])!=index['etag']:raise offsite.Refused()
raw=response.read(65537)
if len(raw)!=index['bytes']:raise offsite.Refused()
r=json.loads(raw)
if (r.get('schema')!='netkingdom.identity-offsite-index.v1' or r.get('status')!='readback_verified'
or r.get('artifact')!=name or r.get('ciphertext_bytes')!=meta['bytes'] or not 0<meta['bytes']<=offsite.LIMIT
or not re.fullmatch('[a-f0-9]{64}',r.get('ciphertext_sha256',''))):raise offsite.Refused()
stamp=datetime.strptime(name.removeprefix('net-kingdom-identity-')[:16],'%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
captured=datetime.fromisoformat(r['captured_at'])
if captured.tzinfo is None or abs((captured-stamp).total_seconds())>3600:raise offsite.Refused()
if stamp>datetime.now(timezone.utc):raise offsite.Refused()
result.append({**r,'stamp':stamp,'etag':meta['etag'],'index_etag':index['etag']})
return sorted(result,key=lambda r:r['stamp'],reverse=True)
def select(rows,protected):
if not rows or protected not in {r['artifact'] for r in rows}:raise offsite.Refused()
keep={protected,rows[0]['artifact']};days=set();weeks=set()
for row in rows:
day=row['stamp'].date();week=row['stamp'].isocalendar()[:2]
if day not in days and len(days)<7:keep.add(row['artifact']);days.add(day)
if week not in weeks and len(weeks)<2:keep.add(row['artifact']);weeks.add(week)
return [r for r in rows if r['artifact'] not in keep],keep
def run(client,token,accepted,receipt,execute=False,checkpoint=lambda:None):
if (accepted.get('status')!='identity_archive_verified' or accepted.get('container_cleanup_verified') is not True
or accepted.get('temporary_plaintext_cleanup_verified') is not True or not offsite.NAME.fullmatch(accepted.get('artifact',''))):raise offsite.Refused()
objects=listing(client,token);rows=generations(client,token,objects)
candidates,keep=select(rows,accepted['artifact'])
recovered=next(r for r in rows if r['artifact']==accepted['artifact'])
if recovered['ciphertext_sha256']!=accepted['ciphertext_sha256']:raise offsite.Refused()
receipt.update(status='retention_preview',protected=accepted['artifact'],keep=sorted(keep),
candidates=[r['artifact'] for r in candidates],deleted=[],unknown_objects_untouched=True)
checkpoint()
# Validate retained known-good and newest ciphertext before any deletion.
with tempfile.TemporaryDirectory(prefix='nk49-retained-check-',dir='/tmp') as temporary:
for index,row in enumerate({r['artifact']:r for r in [recovered,rows[0]]}.values()):
offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/str(index))
if not execute:return
for index,row in enumerate(candidates):
version=offsite.readback(client,token,row['artifact'],row['ciphertext_bytes'],row['ciphertext_sha256'],Path(temporary)/('expire-'+str(index)))
for name,etag in [(row['artifact'],version),(row['artifact']+'.receipt.json',row['index_etag'])]:
h=offsite.headers(token);h['If-Match']=etag
receipt['deletion_in_progress']=name
checkpoint()
with client.open(urllib.request.Request(offsite.BASE+name,method='DELETE',headers=h),timeout=30) as response:
if response.status!=204 or response.url!=offsite.BASE+name:raise offsite.Refused()
receipt['deleted'].append(name)
checkpoint()
receipt.pop('deletion_in_progress',None)
receipt['status']='retention_applied'