#!/usr/bin/env python3
"""TRASENT device receiver, Python 3.9+. Explicit folder, approved releases only.
No third-party modules, no upload, no command execution. Access key is read privately.
"""
import argparse, base64, getpass, hashlib, json, os, pathlib, re, time, urllib.request, urllib.error
BASE=os.environ.get('TRASENT_URL','https://relay-drop-dawid.iwaniuk-dawid.chatgpt.site').rstrip('/')
CHUNK=5*1024*1024
# Public-key ECDSA verification (P-256); never signs or handles a private key.
P=0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
A=P-3
B=0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
N=0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
G=(0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296,0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5)
def ensure(condition,message):
 if not condition:raise ValueError(message)
def dec(v):return base64.urlsafe_b64decode(v+'='*((-len(v))%4))
def add(a,b):
 if a is None:return b
 if b is None:return a
 x,y=a;u,v=b
 if x==u and (y+v)%P==0:return None
 m=((3*x*x+A)*pow(2*y,-1,P) if a==b else (v-y)*pow(u-x,-1,P))%P
 z=(m*m-x-u)%P
 return z,(m*(x-z)-y)%P
def mul(n,p):
 out=None
 while n:
  if n&1:out=add(out,p)
  p=add(p,p);n>>=1
 return out
def verify_release(release,jwk):
 raw=release['canonical'].encode();ensure(hashlib.sha256(raw).hexdigest()==release['hash'],'Manifest digest mismatch')
 ensure(json.loads(raw)==release['manifest'],'Manifest content mismatch')
 ensure(jwk['kty']=='EC' and jwk['crv']=='P-256','Unsupported public key')
 point=(int.from_bytes(dec(jwk['x']),'big'),int.from_bytes(dec(jwk['y']),'big'))
 ensure(0<=point[0]<P and 0<=point[1]<P and (point[1]**2-point[0]**3-A*point[0]-B)%P==0,'Invalid public key')
 signature=dec(release['signature']);ensure(len(signature)==64,'Invalid signature')
 r=int.from_bytes(signature[:32],'big');s=int.from_bytes(signature[32:],'big');ensure(0<r<N and 0<s<N,'Invalid signature range')
 z=int.from_bytes(hashlib.sha256(raw).digest(),'big');w=pow(s,-1,N);out=add(mul(z*w%N,G),mul(r*w%N,point));ensure(out and out[0]%N==r,'Signature verification failed')
 return release['manifest']
def manifest_hash(path):
 parts=[]
 with path.open('rb') as f:
  while True:
   chunk=f.read(CHUNK)
   if not chunk:break
   parts.append(hashlib.sha256(chunk).hexdigest())
 return hashlib.sha256(':'.join(parts).encode()).hexdigest()
def archive_target(root,raw):
 path=pathlib.PurePosixPath(raw)
 ensure(not path.is_absolute() and all(part not in ('..','.','') for part in path.parts),'Invalid archive path')
 safe_parts=[re.sub(r'[<>:"\\|?*\x00-\x1f]','_',part).strip('. ') or 'file' for part in path.parts]
 target=root.joinpath(*safe_parts).resolve();target.relative_to(root)
 return target
def main():
 p=argparse.ArgumentParser(description=__doc__);p.add_argument('--device',required=True);p.add_argument('--folder',required=True);p.add_argument('--once',action='store_true');args=p.parse_args()
 if not re.fullmatch(r'[a-f0-9-]{36}',args.device):p.error('Invalid device identifier')
 token=os.environ.get('TRASENT_DEVICE_KEY') or getpass.getpass('TRASENT device key (hidden): ')
 root=pathlib.Path(args.folder).expanduser().resolve();root.mkdir(parents=True,exist_ok=True);binding=hashlib.sha256(str(root).encode()).hexdigest()
 print('TRASENT will save explicitly confirmed releases ONLY in:',root,flush=True)
 def call(path,method='GET',body=None,headers=None):
  h={'Authorization':'Bearer '+token,'User-Agent':'TrasentDevice/2.0','Accept':'application/json'};h.update(headers or {})
  if body is not None:body=json.dumps(body).encode();h['Content-Type']='application/json'
  return urllib.request.urlopen(urllib.request.Request(BASE+'/api/relay/'+path,method=method,headers=h,data=body),timeout=60)
 with call('devices/'+args.device+'/register','POST',{'folderBinding':binding}) as r:json.load(r)
 while True:
  try:
   with call('devices/'+args.device+'/pending') as r:data=json.load(r)
   ensure(data['device']['folderBinding']==binding,'Folder binding changed')
   for delivery in data['deliveries']:
    ensure(delivery['folder_binding']==binding,'Delivery targets a different folder')
    release=verify_release(delivery['release'],delivery['publicKey']);ensure(delivery['manifest_hash']==delivery['release']['hash'],'Unexpected release')
    receipts=[]
    for asset in release['files']:
     target=archive_target(root,asset['path']);target.parent.mkdir(parents=True,exist_ok=True);partial=target.with_name(target.name+'.trasent-part').resolve();partial.relative_to(root)
     if target.exists() and target.stat().st_size==asset['size'] and manifest_hash(target)==asset['manifestHash']:
      receipts.append({'id':asset['id'],'bytes':asset['size'],'manifestHash':asset['manifestHash']});continue
     ensure(not target.exists(),'Target already exists with different contents; choose another folder')
     offset=partial.stat().st_size if partial.exists() else 0
     if offset>asset['size']:partial.unlink();offset=0
     if offset<asset['size']:
      with call('deliveries/'+delivery['id']+'/files/'+asset['id'],headers={'Range':'bytes='+str(offset)+'-'} if offset else {}) as r:
       if offset and r.status!=206:offset=0
       flags=os.O_WRONLY|os.O_CREAT|(os.O_APPEND if offset else os.O_TRUNC)|getattr(os,'O_NOFOLLOW',0)
       with os.fdopen(os.open(partial,flags,0o600),'ab' if offset else 'wb') as out:
        while True:
         chunk=r.read(1024*1024)
         if not chunk:break
         out.write(chunk)
     ensure(partial.stat().st_size==asset['size'],'Incomplete download')
     if manifest_hash(partial)!=asset['manifestHash']:partial.unlink();raise ValueError('File manifest mismatch')
     partial.replace(target);print('Verified and saved:',target,flush=True);receipts.append({'id':asset['id'],'bytes':asset['size'],'manifestHash':asset['manifestHash']})
    with call('deliveries/'+delivery['id']+'/receipt','POST',{'manifestHash':delivery['manifest_hash'],'folderBinding':binding,'files':receipts}) as r:json.load(r)
    print('Receipt reported for release:',release['id'],flush=True)
   if args.once:break
   time.sleep(15)
  except urllib.error.HTTPError as e:
   if e.code in (401,403,410):raise SystemExit('Access expired or revoked: '+str(e.code))
   if args.once:raise
   print('Server error; retrying:',e.code,flush=True);time.sleep(15)
  except (OSError,ValueError,AssertionError) as e:
   if args.once:raise
   print('Paused, will retry:',str(e),flush=True);time.sleep(15)
if __name__=='__main__':
 try:main()
 except KeyboardInterrupt:print('\nTRASENT stopped. Partial downloads retained for resume.')
