Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
124 lines
6.4 KiB
Python
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}
|