#!/usr/bin/env python3
"""Ed25519 + отпечаток + привязка к выпуску + внешняя публикация ключа.

Проверка подписи не удостоверяет реальность описанного действия. Файлы ключей
и свидетельств не исполняются. Закрытый ключ этому сценарию не нужен.
"""
from __future__ import annotations
import argparse, re, base64, datetime as dt, hashlib, ipaddress, json, socket, sys, ssl, http.client, time, threading, queue
from pathlib import Path
from urllib.parse import urlsplit
from urllib.request import Request, build_opener, HTTPRedirectHandler
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

RELEASE = '4.8.0-wip'
GATES = {'ВР-01', 'ВР-02', 'ВР-03', 'ВР-04'}
FIELDS = {'формат', 'редакция', 'публичное_имя', 'канонический_адрес', 'рубеж',
 'состояние', 'время_наблюдения', 'исполнитель', 'действие', 'результат',
 'ссылка_на_свидетельство', 'ша256_приложения', 'ша256_манифеста_выпуска',
 'ша256_ключа', 'адрес_публикации_ключа', 'граница'}

def canonical(obj):
    # Эта схема разрешает только фиксированные ключи и строковые значения;
    # сортировка Unicode, UTF-8, без пробелов и завершающего перевода строки.
    return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':'), allow_nan=False).encode('utf-8')

def unique(pairs):
    result = {}
    for k,v in pairs:
        if k in result: raise ValueError('Повторный ключ JSON: '+k)
        result[k] = v
    return result

def read_json(path):
    raw = Path(path).read_bytes()
    if len(raw) > 1_048_576: raise ValueError('Свидетельство превышает 1 МиБ')
    return json.loads(raw.decode('utf-8'), object_pairs_hook=unique)

def public_key(raw):
    if raw.startswith(b'-----BEGIN PUBLIC KEY-----'):
        key = serialization.load_pem_public_key(raw)
        if not isinstance(key, Ed25519PublicKey): raise ValueError('Требуется открытый ключ Ed25519')
    else:
        key = Ed25519PublicKey.from_public_bytes(base64.b64decode(raw.strip(), validate=True))
    value = key.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
    return key, hashlib.sha256(value).hexdigest()

def public_url(url, resolve=False):
    if not isinstance(url,str) or any(ord(c)<33 or ord(c)==127 for c in url): raise ValueError('Управляющие знаки или пробелы в адресе ключа запрещены')
    p = urlsplit(url)
    host = (p.hostname or '').encode('idna').decode('ascii').lower().rstrip('.')
    if p.scheme != 'https' or not host or p.username or p.password or p.fragment or p.port not in (None,443):
        raise ValueError('Публикация ключа требует внешнего адреса HTTPS без учётных данных')
    if host == 'xn--80advqd.com' or host.endswith('.xn--80advqd.com'):
        raise ValueError('Ключ должен быть опубликован отдельно от проверяемого сайта')
    try:
        ip = ipaddress.ip_address(host)
        if not ip.is_global: raise ValueError('Недопустимый сетевой адрес ключа')
    except ValueError as e:
        if 'Недопустимый' in str(e): raise
    if resolve:
        addresses = socket.getaddrinfo(host,443,type=socket.SOCK_STREAM)
        if not addresses or any(not ipaddress.ip_address(a[4][0]).is_global for a in addresses):
            raise ValueError('Публикация ключа разрешена только на общедоступном узле')
    return url

class PublicRedirect(HTTPRedirectHandler):
    max_repeats = 2
    max_redirections = 3
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ValueError('Перенаправление публикации ключа не принимается; нужен точный независимо установленный адрес без перенаправлений')

def resolve_public(host, timeout=3.0):
    """Единственное разрешение DNS. Запоздавший ответ не используется."""
    result=queue.Queue(maxsize=1)
    def resolve():
        try: result.put((True,socket.getaddrinfo(host,443,type=socket.SOCK_STREAM)))
        except Exception as exc: result.put((False,exc))
    threading.Thread(target=resolve,daemon=True).start()
    try: ok,value=result.get(timeout=timeout)
    except queue.Empty: raise TimeoutError('DNS не завершён за 3 секунды')
    if not ok: raise value
    if not value or any(a[0] not in (socket.AF_INET,socket.AF_INET6) or not ipaddress.ip_address(a[4][0]).is_global for a in value):
        raise ValueError('Публикация ключа требует только общедоступных адресов; смешанный ответ DNS отклонён')
    return value

class PinnedHTTPSConnection(http.client.HTTPSConnection):
    """Соединение с уже проверенным числовым адресом, TLS проверяет исходное имя."""
    def __init__(self,host,address,timeout):
        super().__init__(host,443,timeout=timeout,context=ssl.create_default_context())
        self.pinned_address=address
    def connect(self):
        family,socktype,proto,_,address=self.pinned_address
        sock=socket.socket(family,socktype,proto)
        try:
            sock.settimeout(self.timeout)
            self.sock=sock
            sock.connect(address)  # tuple из единственного DNS-ответа; имя здесь не используется.
            if ipaddress.ip_address(sock.getpeername()[0])!=ipaddress.ip_address(address[0]):
                raise ValueError('Удалённый адрес не совпал с закреплённым DNS-ответом')
            self.sock=self._context.wrap_socket(sock,server_hostname=self.host)
        except Exception:
            sock.close();self.sock=None;raise

def fetch_public_key(url):
    public_url(url,False)
    parsed=urlsplit(url);host=parsed.hostname.encode('idna').decode('ascii').lower().rstrip('.')
    start=time.monotonic();addresses=resolve_public(host)
    connection=PinnedHTTPSConnection(host,addresses[0],max(0.1,10-(time.monotonic()-start)))
    def abort():
        sock=connection.sock
        if sock is not None:
            try:sock.shutdown(socket.SHUT_RDWR)
            except OSError:pass
            try:sock.close()
            except OSError:pass
    # Общий срок включает медленные TLS, заголовки и поток тела; не обновляется при каждом байте.
    timer=threading.Timer(max(0.1,10-(time.monotonic()-start)),abort);timer.daemon=True;timer.start()
    try:
        target=(parsed.path or '/')+('?' + parsed.query if parsed.query else '')
        connection.request('GET',target,headers={'Accept':'text/plain, application/x-pem-file','Accept-Encoding':'identity','User-Agent':'voina-evidence-verifier/4.5','Connection':'close'})
        response=connection.getresponse()
        if response.status!=200:raise ValueError('Публикация ключа вернула HTTP '+str(response.status)+'; перенаправления не принимаются')
        if response.getheader('Content-Encoding','identity').lower() not in ('identity',''):raise ValueError('Сжатый ответ ключа не принимается')
        length=response.getheader('Content-Length')
        if length is not None and (not length.isdigit() or int(length)>65536):raise ValueError('Неверная длина или опубликованный ключ превышает 64 КиБ')
        chunks=[];total=0
        while True:
            if time.monotonic()-start>=10:raise TimeoutError('Публикация ключа не получена за 10 секунд')
            block=response.read1(min(4096,65537-total))
            if not block:break
            total+=len(block)
            if total>65536:raise ValueError('Опубликованный ключ превышает 64 КиБ')
            chunks.append(block)
        if length is not None and total!=int(length):raise ValueError('Ответ публикации ключа усечён')
        if time.monotonic()-start>=10:raise TimeoutError('Превышен общий срок получения ключа')
        return b''.join(chunks),url
    finally:
        timer.cancel();connection.close()

def verify(record, keybytes, manifestbytes, network=False, fetcher=fetch_public_key, now=None):
    out = {'рубеж':record.get('рубеж') if isinstance(record,dict) else None, 'редакция':RELEASE,
      'подпись_проверена':False, 'публикация_ключа_проверена':False,
      'допуск_к_содержательной_проверке':False, 'рубеж_закрыт':False,
      'независимость_управления_ключом':'требует отдельной проверки владельца и наблюдателя',
      'ограничение':'Подпись связывает байты с ключом, но сама по себе не устанавливает личность или факт исполнения. Разные домены не доказывают независимость управления.', 'ошибки':[]}
    try:
        if not isinstance(record,dict) or set(record) != FIELDS|{'отпечаток_записи','подпись'}:
            raise ValueError('Неполная схема или посторонние поля свидетельства')
        if any(not isinstance(record[k],str) for k in record): raise ValueError('Все поля свидетельства должны быть строками')
        if record['формат']!='война-свидетельство-владельца-2': raise ValueError('Редакция схемы не поддерживается')
        if record['редакция']!=RELEASE: raise ValueError('Другая редакция выпуска')
        if record['публичное_имя']!='война.com' or record['канонический_адрес']!='https://xn--80advqd.com/': raise ValueError('Другой проект или адрес')
        if record['рубеж'] not in GATES: raise ValueError('Неизвестный рубеж')
        if record['состояние']!='заявлено исполнение': raise ValueError('Запись не заявляет исполнение')
        if any(not record[k].strip() for k in FIELDS): raise ValueError('Пустое обязательное поле')
        if not re.fullmatch(r'(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,3})?Z',record['время_наблюдения']): raise ValueError('Требуется однозначное время UTC с окончанием Z')
        when = dt.datetime.fromisoformat(record['время_наблюдения'].replace('Z','+00:00'))
        if when.utcoffset()!=dt.timedelta(0): raise ValueError('Требуется время UTC')
        if when > (now or dt.datetime.now(dt.timezone.utc)): raise ValueError('Наблюдение из будущего')
        for k in ['ша256_приложения','ша256_манифеста_выпуска','ша256_ключа','отпечаток_записи']:
            if len(record[k])!=64 or any(c not in '0123456789abcdef' for c in record[k]): raise ValueError('Недопустимый SHA-256: '+k)
        if record['ша256_манифеста_выпуска']!=hashlib.sha256(manifestbytes).hexdigest(): raise ValueError('Свидетельство относится к другим байтам манифеста')
        if len(manifestbytes)>16*1024*1024: raise ValueError('Манифест превышает 16 МиБ')
        manifest=json.loads(manifestbytes.decode('utf-8'),object_pairs_hook=unique)
        if not isinstance(manifest,dict) or manifest.get('редакция')!=RELEASE or manifest.get('публичное_имя')!='война.com' or manifest.get('канонический_адрес')!='https://xn--80advqd.com/': raise ValueError('Неподходящий проект или редакция самого манифеста')
        entries=manifest.get('файлы',[])
        if not isinstance(entries,list) or not any(isinstance(x,dict) and x.get('путь')=='index.html' for x in entries): raise ValueError('Нужен манифест полного корневого сайта, не отдельного набора')
        key, fingerprint = public_key(keybytes)
        if fingerprint!=record['ша256_ключа']: raise ValueError('Отпечаток ключа не совпал')
        public_url(record['адрес_публикации_ключа'])
        raw = canonical({k:record[k] for k in FIELDS})
        if hashlib.sha256(raw).hexdigest()!=record['отпечаток_записи']: raise ValueError('Отпечаток записи не совпал')
        key.verify(base64.b64decode(record['подпись'],validate=True), raw)
        out['подпись_проверена']=True
        out['ша256_записи']=record['отпечаток_записи']
        if not network:
            out['ошибки'].append('Сетевая проверка публикации ключа не выполнена')
        else:
            fetched, finalurl = fetcher(record['адрес_публикации_ключа'])
            public_url(finalurl)
            if finalurl!=record['адрес_публикации_ключа']: raise ValueError('Адрес после перенаправления отличается; публикация не принята')
            _, remote = public_key(fetched)
            if remote!=fingerprint: raise ValueError('Независимо опубликованный ключ отличается')
            out['публикация_ключа_проверена']=True
            out['проверенный_адрес_ключа']=finalurl
            out['допуск_к_содержательной_проверке']=True
    except Exception as exc:
        out['ошибки'].append(str(exc) or type(exc).__name__)
    return out

def main():
    p=argparse.ArgumentParser(description=__doc__)
    p.add_argument('свидетельство',type=Path);p.add_argument('--ключ',type=Path,required=True)
    p.add_argument('--манифест',type=Path,required=True);p.add_argument('--сеть',action='store_true')
    args=p.parse_args()
    try: result=verify(read_json(args.свидетельство), args.ключ.read_bytes(), args.манифест.read_bytes(), args.сеть)
    except Exception as exc: result={'рубеж_закрыт':False,'ошибки':[str(exc)]}
    print(json.dumps(result,ensure_ascii=False,indent=2))
    return 0 if result.get('допуск_к_содержательной_проверке') else 2
if __name__=='__main__':sys.exit(main())
