#!/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'' 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 03600: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'