#!/usr/bin/env python3
# SPDX-License-Identifier: MIT; Copyright (c) 2026 Kinwilder
# License: https://kinwilder.com/tools/LICENSE.txt
"""Kinwilder publisher v1 client. Requires Python cryptography. Keep keys private.
For Tor: torsocks python3 kw-publish.py --base http://ADDRESS.onion ...
Inspect the script and protocol before use. Network anonymity depends on your setup.
"""
import argparse, base64, hashlib, json, os, secrets, sys, time, urllib.request
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives import serialization
DOMAIN=b'kinwilder.publisher.v1\n'
def b64(data):return base64.urlsafe_b64encode(data).decode().rstrip('=')
def canonical(value):return json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':'),allow_nan=False).encode()
def load(path):
    if os.name!='nt' and path.stat().st_mode & 0o077:raise SystemExit('Private key permissions must be 0600')
    return serialization.load_pem_private_key(path.read_bytes(),password=None)
def pub(key):return b64(key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw))
def main():
    parser=argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--base',default='https://kinwilder.com');parser.add_argument('--key-file',type=Path,default=Path('kw-publisher-key.pem'))
    commands=parser.add_subparsers(dest='command',required=True)
    commands.add_parser('keygen')
    for action in ('enroll','proof','statement','rotate','revoke'):
        p=commands.add_parser(action);p.add_argument('--group',required=True)
        if action=='proof':p.add_argument('--enrollment',required=True);p.add_argument('--proof-url',required=True)
        if action=='statement':p.add_argument('--title',required=True);p.add_argument('--body-file',type=Path,required=True);p.add_argument('--reference',action='append',default=[]);p.add_argument('--license',choices=['CC-BY-4.0'],required=True)
        if action=='rotate':p.add_argument('--new-key-file',type=Path,required=True)
        if action=='revoke':p.add_argument('--reason',required=True)
    args=parser.parse_args()
    from urllib.parse import urlsplit
    base=args.base.rstrip('/');parsed=urlsplit(base)
    if parsed.scheme!='https' and not (parsed.scheme=='http' and parsed.hostname and parsed.hostname.endswith('.onion')):raise SystemExit('Use HTTPS or an onion address through Tor')
    if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('','/'):raise SystemExit('Expected an origin URL')
    if args.command=='keygen':
        key=Ed25519PrivateKey.generate();fd=os.open(args.key_file,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
        with os.fdopen(fd,'wb') as f:f.write(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
        print(json.dumps({'publicKey':pub(key),'keyId':hashlib.sha256(key.public_key().public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw)).hexdigest()}));return
    key=load(args.key_file)
    if not isinstance(key,Ed25519PrivateKey):raise SystemExit('An Ed25519 private key is required')
    def request(path,body=None):
        req=urllib.request.Request(base+path,data=body,headers={'Content-Type':'application/json','Accept':'application/json','User-Agent':'KinwilderPublisher/1'})
        with urllib.request.urlopen(req,timeout=90) as response:
            raw=response.read(262145)
            if len(raw)>262144:raise SystemExit('Oversized response')
            return json.loads(raw)
    work=request('/api/v1/challenge');token=work['token'];bits=work['difficulty']
    if not isinstance(bits,int) or not 0<=bits<=22:raise SystemExit('Unexpected work threshold')
    print('Solving bounded intake work challenge…',file=sys.stderr)
    counter=0
    while int.from_bytes(hashlib.sha256((token+':'+str(counter)).encode()).digest(),'big')>>(256-bits):counter+=1
    p={'v':1,'action':args.command,'group':args.group,'key':pub(key),'ts':int(time.time()),'nonce':b64(secrets.token_bytes(16))}
    if args.command=='proof':p.update(enrollment=args.enrollment,proofUrl=args.proof_url)
    if args.command=='statement':p.update(title=args.title,body=args.body_file.read_text(encoding='utf-8'),references=args.reference,license=args.license)
    if args.command=='revoke':p['reason']=args.reason
    if args.command=='rotate':p['newKey']=pub(load(args.new_key_file))
    envelope={'payload':p,'signature':b64(key.sign(DOMAIN+canonical(p))),'work':{'token':token,'counter':str(counter)}}
    if args.command=='rotate':envelope['newSignature']=b64(load(args.new_key_file).sign(DOMAIN+canonical(p)))
    encoded=canonical(envelope)
    if len(encoded)>16384:raise SystemExit('Submission exceeds 16 KiB')
    endpoint={'enroll':'enrollments','proof':'proofs','statement':'statements','rotate':'rotate','revoke':'revoke'}[args.command]
    print(json.dumps(request('/api/v1/'+endpoint,encoded),indent=2))
if __name__=='__main__':main()
