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