#!/usr/bin/env python3
"""Niezalezny weryfikator paczki Akt. Tylko biblioteka standardowa Pythona 3.
Uzycie:  python3 akta_verify.py <paczka.zip|katalog>
Laczy: pliki -> hasze manifestu -> pieczec -> znacznik czasu RFC 3161.
"""
import base64, hashlib, json, math, os, stat, struct, sys, unicodedata, zipfile

STANDARD_MANIFEST = '01_MANIFEST.json'
COURT_MANIFEST = '01_MANIFEST_SADOWY.json'
TRUSTED_AKTA_KEYS_B64 = {'MMqMJ054EUnnEwjSNpiYkZIIQ6c7WcKijYghXtqWxZo=': 'current'}
MAX_ZIP_ENTRIES = 5000
MAX_ENTRY_BYTES = 160 * 1024 * 1024
MAX_TOTAL_BYTES = 384 * 1024 * 1024
MAX_COMPRESSION_RATIO = 200
MAX_METADATA_BYTES = 16 * 1024 * 1024
MAX_CENTRAL_DIRECTORY_BYTES = 16 * 1024 * 1024
WINDOWS_RESERVED_BASENAMES = {
    'CON', 'PRN', 'AUX', 'NUL',
    *('COM%d' % index for index in range(1, 10)),
    *('LPT%d' % index for index in range(1, 10)),
}

# Parametry Ed25519 z RFC 8032. Implementacja jest celowo lokalna, aby odbiorca
# mógł kryptograficznie sprawdzić podpis bez instalowania bibliotek Pythona.
_Q = 2 ** 255 - 19
_L = 2 ** 252 + 27742317777372353535851937790883648493
_D = (-121665 * pow(121666, _Q - 2, _Q)) % _Q
_I = pow(2, (_Q - 1) // 4, _Q)


def _xrecover(y):
    xx = (y * y - 1) * pow(_D * y * y + 1, _Q - 2, _Q) % _Q
    x = pow(xx, (_Q + 3) // 8, _Q)
    if (x * x - xx) % _Q:
        x = x * _I % _Q
    if x & 1:
        x = _Q - x
    return x


_BY = 4 * pow(5, _Q - 2, _Q) % _Q
_BX = _xrecover(_BY)
_B = (_BX, _BY, 1, _BX * _BY % _Q)
_IDENTITY = (0, 1, 1, 0)


def _point_add(p, q):
    x1, y1, z1, t1 = p
    x2, y2, z2, t2 = q
    a = (y1 - x1) * (y2 - x2) % _Q
    b = (y1 + x1) * (y2 + x2) % _Q
    c = 2 * _D * t1 * t2 % _Q
    d = 2 * z1 * z2 % _Q
    e, f, g, h = b - a, d - c, d + c, b + a
    return (e * f % _Q, g * h % _Q, f * g % _Q, e * h % _Q)


def _scalarmult(point, scalar):
    out = _IDENTITY
    current = point
    while scalar:
        if scalar & 1:
            out = _point_add(out, current)
        current = _point_add(current, current)
        scalar >>= 1
    return out


def _decodepoint(encoded):
    if len(encoded) != 32:
        raise ValueError('wrong point length')
    raw = int.from_bytes(encoded, 'little')
    y = raw & ((1 << 255) - 1)
    if y >= _Q:
        raise ValueError('non-canonical point')
    x = _xrecover(y)
    if (x & 1) != (raw >> 255):
        x = _Q - x
    if (-x * x + y * y - 1 - _D * x * x * y * y) % _Q:
        raise ValueError('point off curve')
    return (x, y, 1, x * y % _Q)


def _encodepoint(point):
    x, y, z, _ = point
    zinv = pow(z, _Q - 2, _Q)
    x, y = x * zinv % _Q, y * zinv % _Q
    return int.to_bytes(y | ((x & 1) << 255), 32, 'little')


def ed25519_verify(message, signature_b64, public_key_b64):
    try:
        signature = base64.b64decode(signature_b64, validate=True)
        public_key = base64.b64decode(public_key_b64, validate=True)
        if len(signature) != 64 or len(public_key) != 32:
            return False
        r_encoded, s_encoded = signature[:32], signature[32:]
        s = int.from_bytes(s_encoded, 'little')
        if s >= _L:
            return False
        r_point = _decodepoint(r_encoded)
        a_point = _decodepoint(public_key)
        challenge = int.from_bytes(
            hashlib.sha512(r_encoded + public_key + message).digest(), 'little'
        ) % _L
        return _encodepoint(_scalarmult(_B, s)) == _encodepoint(
            _point_add(r_point, _scalarmult(a_point, challenge))
        )
    except Exception:
        return False


def sha256_bytes(data):
    return hashlib.sha256(data).hexdigest()


def valid_hex(value, length):
    if not isinstance(value, str) or len(value) != length:
        return False
    try:
        int(value, 16)
        return True
    except ValueError:
        return False


def canonical_sha256(obj):
    return hashlib.sha256(
        json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')
    ).hexdigest()


def canonical_bytes(obj):
    return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode('utf-8')


def strict_json_loads(data):
    def reject_constant(value):
        raise ValueError('niestandardowa stala JSON: %s' % value)
    def finite_float(value):
        parsed = float(value)
        if not math.isfinite(parsed):
            raise ValueError('nieskonczona liczba JSON')
        return parsed
    return json.loads(data, parse_constant=reject_constant, parse_float=finite_float)


def preflight_zip_file(path):
    with open(path, 'rb') as source:
        source.seek(0, os.SEEK_END)
        archive_size = source.tell()
        if archive_size < 22:
            raise ValueError('brak kompletnego rekordu EOCD')
        tail_size = min(archive_size, 22 + 65535)
        tail_offset = archive_size - tail_size
        source.seek(tail_offset)
        tail = source.read(tail_size)
        if len(tail) != tail_size:
            raise ValueError('uciety rekord struktury ZIP')
        candidates = []
        search_from = 0
        while True:
            relative_eocd = tail.find(b'PK\x05\x06', search_from)
            if relative_eocd < 0:
                break
            if relative_eocd + 22 <= len(tail):
                comment_length = struct.unpack_from('<H', tail, relative_eocd + 20)[0]
                if tail_offset + relative_eocd + 22 + comment_length == archive_size:
                    candidates.append(relative_eocd)
            search_from = relative_eocd + 1
        if not candidates:
            raise ValueError('brak kanonicznego rekordu EOCD')
        if len(candidates) != 1:
            raise ValueError('niejednoznaczne, wielokrotne rekordy EOCD')
        relative_eocd = candidates[0]
        fields = struct.unpack_from('<4s4H2IH', tail, relative_eocd)
        disk_number, central_disk = fields[1], fields[2]
        entries_on_disk, declared_entries = fields[3], fields[4]
        central_size, central_offset = fields[5], fields[6]
        if (
            disk_number == 0xffff or central_disk == 0xffff
            or entries_on_disk == 0xffff or declared_entries == 0xffff
            or central_size == 0xffffffff or central_offset == 0xffffffff
        ):
            raise ValueError('ZIP64 nie jest obslugiwany')
        if disk_number != 0 or central_disk != 0 or entries_on_disk != declared_entries:
            raise ValueError('wielodyskowy albo niespojny rekord EOCD')
        if declared_entries > MAX_ZIP_ENTRIES:
            raise ValueError('zbyt wiele wpisow ZIP')
        if central_size > MAX_CENTRAL_DIRECTORY_BYTES:
            raise ValueError('central directory przekracza limit')
        absolute_eocd = tail_offset + relative_eocd
        central_end = central_offset + central_size
        if central_end != absolute_eocd:
            raise ValueError('niekanoniczne polozenie central directory')
        cursor = central_offset
        actual_entries = 0
        local_records = []
        while cursor < central_end:
            if actual_entries >= MAX_ZIP_ENTRIES:
                raise ValueError('zbyt wiele wpisow ZIP')
            source.seek(cursor)
            header = source.read(46)
            if len(header) != 46:
                raise ValueError('uciety rekord central directory')
            item = struct.unpack('<4s6H3I5H2I', header)
            if item[0] != b'PK\x01\x02':
                raise ValueError('nieprawidlowy rekord central directory')
            if (
                item[8] == 0xffffffff or item[9] == 0xffffffff
                or item[13] == 0xffff or item[16] == 0xffffffff
            ):
                raise ValueError('wpis ZIP64 nie jest obslugiwany')
            if item[13] != 0 or item[16] + 30 > central_offset:
                raise ValueError('nieprawidlowe polozenie wpisu ZIP')
            record_size = 46 + item[10] + item[11] + item[12]
            if cursor + record_size > central_end:
                raise ValueError('rekord central directory wykracza poza limit')
            central_filename = source.read(item[10])
            if len(central_filename) != item[10]:
                raise ValueError('ucieta nazwa central directory')
            local_records.append((
                item[16], item[8], item[3], item[4], central_filename,
            ))
            cursor += record_size
            actual_entries += 1
        if cursor != central_end or actual_entries != declared_entries:
            raise ValueError('rzeczywista liczba wpisow ZIP nie zgadza sie z EOCD')
        local_offsets = [record[0] for record in local_records]
        if len(local_offsets) != len(set(local_offsets)):
            raise ValueError('wiele wpisow wskazuje ten sam naglowek lokalny ZIP')
        if actual_entries == 0:
            if central_offset != 0:
                raise ValueError('puste archiwum ZIP zawiera prefiks')
        elif min(local_offsets) != 0:
            raise ValueError('archiwum ZIP zawiera prefiks')
        local_ranges = []
        for offset, compressed_size, central_flags, central_method, central_name in local_records:
            source.seek(offset)
            local_header = source.read(30)
            if len(local_header) != 30:
                raise ValueError('uciety naglowek lokalny ZIP')
            local = struct.unpack('<4s5H3I2H', local_header)
            if local[0] != b'PK\x03\x04':
                raise ValueError('offset nie wskazuje naglowka lokalnego ZIP')
            if local[2] != central_flags or local[3] != central_method:
                raise ValueError('lokalny i centralny naglowek ZIP sa niespojne')
            local_name = source.read(local[9])
            if local_name != central_name:
                raise ValueError('lokalna i centralna nazwa ZIP sa rozne')
            data_start = offset + 30 + local[9] + local[10]
            data_end = data_start + compressed_size
            if data_end > central_offset:
                raise ValueError('dane wpisu wykraczaja poza obszar danych ZIP')
            local_ranges.append((offset, data_end))
        local_ranges.sort()
        for previous, current in zip(local_ranges, local_ranges[1:]):
            if previous[1] > current[0]:
                raise ValueError('lokalne zakresy wpisow ZIP nakladaja sie')
        source.seek(0)
        return os.fdopen(os.dup(source.fileno()), 'rb')


def safe_path(value):
    if not isinstance(value, str):
        return None
    path = value
    parts = path.split('/')
    if (
        not path
        or path != path.strip()
        or path != unicodedata.normalize('NFC', path)
        or path.startswith('/')
        or '\\' in path
        or len(path.encode('utf-8')) > 4096
        or any(ord(ch) < 32 or 127 <= ord(ch) <= 159 for ch in path)
        or any(p in ('', '.', '..') for p in parts)
    ):
        return None
    for part in parts:
        if (
            part.endswith((' ', '.'))
            or ':' in part
            or any(ch in '*?"<>|' for ch in part)
            or len(part.encode('utf-8')) > 255
            or part.split('.', 1)[0].upper() in WINDOWS_RESERVED_BASENAMES
        ):
            return None
    return path


def _read_bounded(stream, limit):
    payload = bytearray()
    while True:
        chunk = stream.read(min(1024 * 1024, limit + 1 - len(payload)))
        if not chunk:
            break
        payload.extend(chunk)
        if len(payload) > limit:
            raise ValueError('odczyt przekracza dozwolony limit')
    return bytes(payload)


def _hash_stream(stream, limit):
    sha256 = hashlib.sha256()
    sha512 = hashlib.sha512()
    size = 0
    while True:
        chunk = stream.read(1024 * 1024)
        if not chunk:
            break
        size += len(chunk)
        if size > limit:
            raise ValueError('odczyt przekracza dozwolony limit')
        sha256.update(chunk)
        sha512.update(chunk)
    return sha256.hexdigest(), sha512.hexdigest(), size


def load(arg):
    """Zwraca read/hash/listę nazw po bezpiecznym skanie całej paczki."""
    if os.path.isdir(arg):
        root_stat = os.lstat(arg)
        if stat.S_ISLNK(root_stat.st_mode) or not stat.S_ISDIR(root_stat.st_mode):
            raise ValueError('katalog paczki musi byc rzeczywistym katalogiem')
        names = []
        total = 0
        scanned_entries = 0
        snapshots = {}
        portable_names = {}
        root_path = os.path.realpath(arg)
        def walk_error(exc):
            raise exc
        for root, dirs, fs in os.walk(arg, topdown=True, onerror=walk_error):
            for directory in dirs:
                full = os.path.join(root, directory)
                info = os.lstat(full)
                if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
                    raise ValueError('niebezpieczny katalog w paczce: %s' % full)
                rel = os.path.relpath(full, arg).replace(os.sep, '/')
                if safe_path(rel) != rel:
                    raise ValueError('niebezpieczna sciezka katalogu paczki: %s' % rel)
                portable = rel.casefold()
                if portable in portable_names:
                    raise ValueError('kolizja przenosnej nazwy w katalogu paczki: %s' % rel)
                portable_names[portable] = rel
                scanned_entries += 1
            for f in fs:
                full = os.path.join(root, f)
                info = os.lstat(full)
                if stat.S_ISLNK(info.st_mode):
                    raise ValueError('dowiazanie symboliczne w katalogu paczki: %s' % full)
                if not stat.S_ISREG(info.st_mode):
                    raise ValueError('plik specjalny w katalogu paczki: %s' % full)
                rel = os.path.relpath(full, arg).replace(os.sep, '/')
                if safe_path(rel) != rel or not os.path.realpath(full).startswith(root_path + os.sep):
                    raise ValueError('niebezpieczna sciezka w katalogu paczki: %s' % rel)
                portable = rel.casefold()
                if portable in portable_names:
                    raise ValueError('kolizja przenosnej nazwy w katalogu paczki: %s' % rel)
                portable_names[portable] = rel
                size = info.st_size
                if size > MAX_ENTRY_BYTES:
                    raise ValueError('plik przekracza limit: %s' % rel)
                total += size
                names.append(rel)
                snapshots[rel] = (info.st_dev, info.st_ino, info.st_size)
                scanned_entries += 1
        if scanned_entries > MAX_ZIP_ENTRIES or total > MAX_TOTAL_BYTES:
            raise ValueError('katalog paczki przekracza limit liczby lub rozmiaru plikow')
        root_flags = os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0)
        root_fd = os.open(arg, root_flags)
        supports_dir_fd = os.open in getattr(os, 'supports_dir_fd', set())
        def open_member(p):
            if safe_path(p) != p or p not in snapshots:
                raise ValueError('niebezpieczna albo nieznana sciezka: %s' % p)
            flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0)
            if supports_dir_fd:
                directory_fd = os.dup(root_fd)
                try:
                    parts = p.split('/')
                    for part in parts[:-1]:
                        next_fd = os.open(
                            part,
                            os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0),
                            dir_fd=directory_fd,
                        )
                        os.close(directory_fd)
                        directory_fd = next_fd
                    descriptor = os.open(parts[-1], flags, dir_fd=directory_fd)
                finally:
                    os.close(directory_fd)
            else:
                full = os.path.join(arg, p.replace('/', os.sep))
                if os.path.islink(full) or not os.path.realpath(full).startswith(root_path + os.sep):
                    raise ValueError('niebezpieczna sciezka: %s' % p)
                descriptor = os.open(full, flags)
            info = os.fstat(descriptor)
            if not stat.S_ISREG(info.st_mode) or (info.st_dev, info.st_ino, info.st_size) != snapshots[p]:
                os.close(descriptor)
                raise ValueError('plik zmienil sie po skanowaniu paczki: %s' % p)
            return os.fdopen(descriptor, 'rb')
        def read(p):
            with open_member(p) as fh:
                return _read_bounded(fh, MAX_METADATA_BYTES)
        def digest(p):
            with open_member(p) as fh:
                return _hash_stream(fh, MAX_ENTRY_BYTES)
        directory_closed = [False]
        def close_directory():
            if not directory_closed[0]:
                directory_closed[0] = True
                os.close(root_fd)
        read.close = close_directory
        digest.close = close_directory
        return read, digest, names
    zip_source = preflight_zip_file(arg)
    try:
        z = zipfile.ZipFile(zip_source)
    except Exception:
        zip_source.close()
        raise
    zip_closed = [False]
    def close_zip():
        if zip_closed[0]:
            return
        zip_closed[0] = True
        try:
            z.close()
        finally:
            zip_source.close()
    def reject_zip(message):
        close_zip()
        raise ValueError(message)
    infos = z.infolist()
    if len(infos) > MAX_ZIP_ENTRIES:
        reject_zip('zbyt wiele wpisow ZIP')
    total = 0
    names = []
    raw = {}
    portable_names = {}
    for info in infos:
        raw_name = getattr(info, 'orig_filename', info.filename)
        if '\x00' in raw_name or raw_name != info.filename:
            reject_zip('niekanoniczna nazwa ZIP z NUL: %r' % raw_name)
        mode = (info.external_attr >> 16) & 0xFFFF
        file_type = stat.S_IFMT(mode)
        if stat.S_ISLNK(mode):
            reject_zip('dowiazanie symboliczne ZIP: %s' % info.filename)
        if file_type not in (0, stat.S_IFREG, stat.S_IFDIR):
            reject_zip('plik specjalny ZIP: %s' % info.filename)
        if info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
            reject_zip('nieobslugiwana metoda kompresji: %s' % info.filename)
        if info.flag_bits & 1:
            reject_zip('zaszyfrowany wpis ZIP: %s' % info.filename)
        if info.is_dir():
            if file_type == stat.S_IFREG or info.file_size:
                reject_zip('niespojny wpis katalogu ZIP: %s' % info.filename)
            directory = info.filename.rstrip('/').replace('\\', '/')
            if safe_path(directory) != directory or directory != info.filename.rstrip('/'):
                reject_zip('niekanoniczna sciezka katalogu ZIP: %s' % info.filename)
            portable = directory.casefold()
            if portable in portable_names:
                reject_zip('kolizja przenosnej nazwy ZIP: %s' % directory)
            portable_names[portable] = directory
            continue
        if file_type == stat.S_IFDIR:
            reject_zip('niespojny typ zwyklego wpisu ZIP: %s' % info.filename)
        normalized = info.filename.replace('\\', '/')
        if safe_path(normalized) != normalized or normalized != info.filename:
            reject_zip('niekanoniczna lub niebezpieczna sciezka ZIP: %s' % info.filename)
        portable = normalized.casefold()
        if portable in portable_names:
            reject_zip('powtorzona lub kolizyjna sciezka ZIP: %s' % normalized)
        portable_names[portable] = normalized
        if info.file_size > MAX_ENTRY_BYTES:
            reject_zip('wpis przekracza limit rozpakowania: %s' % normalized)
        if info.compress_size > 0 and info.file_size > 1000000 and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
            reject_zip('podejrzany wspolczynnik kompresji: %s' % normalized)
        total += info.file_size
        names.append(normalized)
        raw[normalized] = info
    if total > MAX_TOTAL_BYTES:
        reject_zip('suma rozpakowanej zawartosci przekracza limit')
    file_keys = {name.casefold() for name in raw}
    for name in raw:
        parts = name.split('/')
        for index in range(1, len(parts)):
            if '/'.join(parts[:index]).casefold() in file_keys:
                reject_zip('kolizja pliku i katalogu ZIP: %s' % name)
    def read(p):
        if safe_path(p) != p or p not in raw:
            raise ValueError('niebezpieczna albo nieznana sciezka: %s' % p)
        info = raw[p]
        if info.file_size > MAX_METADATA_BYTES:
            raise ValueError('metadane przekraczaja limit: %s' % p)
        with z.open(info, 'r') as fh:
            payload = _read_bounded(fh, MAX_METADATA_BYTES)
        if len(payload) != info.file_size:
            raise ValueError('niespojny rzeczywisty rozmiar wpisu ZIP: %s' % p)
        return payload
    def digest(p):
        if safe_path(p) != p or p not in raw:
            raise ValueError('niebezpieczna albo nieznana sciezka: %s' % p)
        info = raw[p]
        with z.open(info, 'r') as fh:
            result = _hash_stream(fh, MAX_ENTRY_BYTES)
        if result[2] != info.file_size:
            raise ValueError('niespojny rzeczywisty rozmiar wpisu ZIP: %s' % p)
        return result
    read.close = close_zip
    digest.close = close_zip
    return read, digest, names


def find_root(names, basename):
    """Plik o danej nazwie najblizej KORZENIA paczki (najmniej '/') — anti-shadowing."""
    cands = [n for n in names if n.split('/')[-1] == basename]
    cands.sort(key=lambda n: (n.count('/'), len(n)))
    return cands[0] if cands else None


def der_len(n):
    if n < 0x80:
        return bytes([n])
    out = b''
    while n:
        out = bytes([n & 0xFF]) + out
        n >>= 8
    return bytes([0x80 | len(out)]) + out


def der(tag, value):
    return bytes([tag]) + der_len(len(value)) + value


def message_imprints(digest_hex):
    d = bytes.fromhex(digest_hex)
    oid = bytes.fromhex('0609608648016503040201')   # OID sha256
    null = bytes.fromhex('0500')
    octet = der(0x04, d)
    algid_null = der(0x30, oid + null)
    algid_bare = der(0x30, oid)
    return [der(0x30, algid_null + octet), der(0x30, algid_bare + octet)]


def main():
    if len(sys.argv) < 2:
        print('Uzycie: python3 akta_verify.py <paczka.zip|katalog>'); sys.exit(2)
    try:
        read, digest, names = load(sys.argv[1])
    except Exception as exc:
        print('BLAD: nie mozna bezpiecznie otworzyc paczki: %s' % exc); sys.exit(2)
    try:
        return verify_loaded(read, digest, names)
    finally:
        close = getattr(read, 'close', None)
        if close:
            close()


def verify_loaded(read, digest, names):
    print('UWAGA: dla niezaleznego przypisania podpisu Akcie uzyj kopii weryfikatora')
    print('pobranej osobnym, zaufanym kanalem z https://getakta.com/akta_verify.py.')
    seen_names = set()
    duplicate_names = []
    for name in names:
        if name in seen_names and name not in duplicate_names:
            duplicate_names.append(name)
        seen_names.add(name)
    if duplicate_names:
        print('BLAD: paczka zawiera powtorzone, niejednoznaczne sciezki ZIP:')
        for name in sorted(duplicate_names):
            print('  ! %s' % name)

    root_manifests = [name for name in (STANDARD_MANIFEST, COURT_MANIFEST) if name in names]
    all_manifests = [
        name for name in names
        if name.split('/')[-1] in (STANDARD_MANIFEST, COURT_MANIFEST)
    ]
    if len(root_manifests) != 1 or len(all_manifests) != 1:
        print('BLAD: paczka musi zawierac dokladnie jeden obslugiwany manifest w korzeniu.'); sys.exit(2)
    man_name = root_manifests[0]
    try:
        manifest = strict_json_loads(read(man_name).decode('utf-8'))
        if not isinstance(manifest, dict):
            raise ValueError('manifest nie jest obiektem JSON')
    except Exception as e:
        print('BLAD: nie udalo sie wczytac manifestu: %s' % e); sys.exit(2)

    is_court = man_name.split('/')[-1] == COURT_MANIFEST or manifest.get('kind') == 'court_submission_package'
    contract_error = 0
    if is_court:
        if man_name != COURT_MANIFEST:
            print('BLAD: paczka sadowa musi uzywac manifestu %s.' % COURT_MANIFEST)
            contract_error = 1
        if manifest.get('schema_version') != '5.0-court-package':
            print('BLAD: nieobslugiwana wersja schematu paczki sadowej.')
            contract_error = 1
        if manifest.get('kind') != 'court_submission_package':
            print('BLAD: nieprawidlowy rodzaj paczki sadowej.')
            contract_error = 1
    else:
        if man_name != STANDARD_MANIFEST:
            print('BLAD: standardowa paczka musi uzywac manifestu %s.' % STANDARD_MANIFEST)
            contract_error = 1
        if manifest.get('schema_version') != '5.0-production-foundation':
            print('BLAD: nieobslugiwana wersja schematu standardowej paczki.')
            contract_error = 1
    evidence = manifest.get('evidence', []) or []
    expected_paths = {}
    evidence_manifest_error = 0
    if not isinstance(evidence, list):
        print('BLAD: pole evidence w manifescie nie jest lista.')
        evidence_manifest_error = 1
        evidence = []
    if len(evidence) > MAX_ZIP_ENTRIES:
        print('BLAD: manifest zawiera zbyt wiele wpisow evidence.'); sys.exit(2)
    for ev in evidence:
        if not isinstance(ev, dict):
            evidence_manifest_error = 1
            continue
        if is_court and ev.get('file_included') is not True:
            print('  ! POWOLANY DOWOD NIE JEST OZNACZONY JAKO DOLACZONY: %s' % ev.get('evidence_id'))
            evidence_manifest_error = 1
        p = safe_path(ev.get('package_path'))
        if p:
            if p in expected_paths:
                print('  ! POWTORZONA SCIEZKA MATERIALU W MANIFESCIE: %s' % p)
                evidence_manifest_error = 1
            expected_paths[p] = (
                (ev.get('sha256') or '').strip().lower(),
                (ev.get('sha512') or '').strip().lower(),
                ev.get('size_bytes'),
                True,
            )
        else:
            print('  ! NIEPRAWIDLOWA SCIEZKA MATERIALU: %s' % ev.get('package_path'))
            evidence_manifest_error = 1
    if is_court:
        pleading = manifest.get('pleading') or {}
        pleading_path = safe_path(pleading.get('path'))
        pleading_sha = (pleading.get('sha256') or '').strip().lower()
        if pleading_path and pleading_sha and pleading_path not in expected_paths:
            expected_paths[pleading_path] = (pleading_sha, '', None, False)
        else:
            print('  ! NIEPRAWIDLOWA, KOLIZYJNA SCIEZKA LUB HASH PISMA: %s' % pleading.get('path'))
            evidence_manifest_error = 1

    # Raporty operacyjne (audyt, zaufanie, storage, dostęp/retencja) również są
    # objęte zapieczętowanym manifestem. Sama ich obecność w ZIP-ie nie wystarcza.
    artifact_entries = manifest.get('package_artifacts', [])
    expected_artifacts = {}
    artifact_manifest_error = 0
    if not isinstance(artifact_entries, list) or not artifact_entries:
        print('BLAD: manifest nie zawiera zapieczetowanego wykazu artefaktow paczki.')
        artifact_manifest_error = 1
        artifact_entries = []
    if len(artifact_entries) > MAX_ZIP_ENTRIES:
        print('BLAD: manifest zawiera zbyt wiele artefaktow.'); sys.exit(2)
    for item in artifact_entries:
        if not isinstance(item, dict):
            artifact_manifest_error = 1
            continue
        p = safe_path(item.get('path'))
        if not p:
            print('  ! NIEPRAWIDLOWA SCIEZKA ARTEFAKTU: %s' % p)
            artifact_manifest_error = 1
            continue
        if p in expected_artifacts:
            print('  ! POWTORZONA SCIEZKA ARTEFAKTU: %s' % p)
            artifact_manifest_error = 1
            continue
        if p in expected_paths:
            print('  ! SCIEZKA JEST JEDNOCZESNIE MATERIALEM I ARTEFAKTEM: %s' % p)
            artifact_manifest_error = 1
            continue
        expected_artifacts[p] = (
            (item.get('sha256') or '').strip().lower(),
            (item.get('sha512') or '').strip().lower(),
            item.get('size_bytes'),
        )

    document_seal_error = 0
    if is_court:
        required_court_artifacts = {
            '02_MAPA_DOWODOWA.csv',
            '03_JAK_ZWERYFIKOWAC.txt',
            '05_PIECZEC_PISMA.json',
            'AKTA_WERYFIKATOR.py',
        }
        hearing = manifest.get('hearing_bundle') or {}
        if isinstance(hearing, dict) and hearing.get('available') is True:
            for field in ('path', 'report_path'):
                required_path = safe_path(hearing.get(field))
                if required_path:
                    required_court_artifacts.add(required_path)
                else:
                    print('  ! NIEPRAWIDLOWA SCIEZKA TECZKI ROZPRAWOWEJ: %s' % hearing.get(field))
                    artifact_manifest_error = 1
        if manifest.get('case_seal'):
            required_court_artifacts.add('06_PIECZEC_AKT.json')
        if manifest.get('formal_check') is not None:
            required_court_artifacts.add('07_BRAKI_FORMALNE.json')
        for required in sorted(required_court_artifacts):
            if required not in expected_artifacts:
                print('  ! BRAK WYMAGANEGO ARTEFAKTU PACZKI SADOWEJ W MANIFESCIE: %s' % required)
                artifact_manifest_error = 1

        try:
            detached_document_seal = strict_json_loads(read('05_PIECZEC_PISMA.json').decode('utf-8'))
            public_document_seal = dict(detached_document_seal)
            detached_signature = public_document_seal.pop('signature', None)
            document_seal_ok = (
                isinstance(manifest.get('document_seal'), dict)
                and canonical_bytes(public_document_seal) == canonical_bytes(manifest.get('document_seal'))
                and detached_document_seal.get('document_sha256') == pleading_sha
                and valid_hex(detached_signature, 64)
            )
        except Exception:
            document_seal_ok = False
        if document_seal_ok:
            print('  OK  Pieczec pisma jest zwiazana z pismem i podpisanym manifestem.')
        else:
            print('  ! PIECZEC PISMA NIE JEST ZWIAZANA Z PISMEM LUB MANIFESTEM.')
            document_seal_error = 1

    ok = bad = missing = extra = 0
    print('== Weryfikacja plikow wzgledem manifestu (SHA-256) ==')
    for p, expected in sorted(expected_paths.items()):
        sha, sha512, expected_size, require_secondary = expected
        if p not in names:
            print('  ? BRAK PLIKU: %s' % p); missing += 1; continue
        actual, actual_sha512, actual_size = digest(p)
        sha512_ok = (not require_secondary and not sha512) or (
            valid_hex(sha512, 128) and actual_sha512 == sha512
        )
        size_ok = (
            (not require_secondary and expected_size is None)
            or (isinstance(expected_size, int) and actual_size == expected_size)
        )
        if valid_hex(sha, 64) and actual == sha and sha512_ok and size_ok:
            print('  OK  %s' % p); ok += 1
        else:
            print('  ZLY HASH LUB ROZMIAR: %s\n      manifest SHA-256: %s\n      plik SHA-256:     %s' % (p, sha, actual)); bad += 1

    artifact_ok = artifact_bad = artifact_missing = 0
    print('\n== Weryfikacja zapieczetowanych artefaktow paczki ==')
    for p, expected in sorted(expected_artifacts.items()):
        sha, sha512, expected_size = expected
        if p not in names:
            print('  ? BRAK ARTEFAKTU: %s' % p); artifact_missing += 1; continue
        actual, actual_sha512, actual_size = digest(p)
        sha512_ok = valid_hex(sha512, 128) and actual_sha512 == sha512
        if valid_hex(sha, 64) and actual == sha and sha512_ok and isinstance(expected_size, int) and actual_size == expected_size:
            print('  OK  %s' % p); artifact_ok += 1
        else:
            print('  ZLY ARTEFAKT: %s\n      manifest SHA-256: %s\n      plik SHA-256:     %s\n      manifest bajty:   %s\n      plik bajty:       %s' % (
                p, sha, actual, expected_size, actual_size,
            ))
            artifact_bad += 1
    if not is_court:
        for required in ('09_AUDIT_CHAIN.json', '10_TRUST_PROFILE.json', '11_STORAGE_CONTRACT.json', '12_ACCESS_AND_RETENTION_SUMMARY.json'):
            if required not in expected_artifacts:
                print('  ! BRAK ARTEFAKTU W ZAPIECZETOWANYM MANIFESCIE: %s' % required)
                artifact_manifest_error = 1

    # Pełna allowlista paczki: poza manifestem, jego pieczęcią i opcjonalnym tokenem
    # czasu każdy plik musi być dowodem/pismem albo zapieczętowanym artefaktem.
    seal_name = '08_MANIFEST_SEAL.json' if '08_MANIFEST_SEAL.json' in names else None
    tsr_name = '13_TIMESTAMP.tsr' if '13_TIMESTAMP.tsr' in names else None
    ts_json_name = '13_TIMESTAMP.json' if '13_TIMESTAMP.json' in names else None
    infrastructure = {name for name in (man_name, seal_name, tsr_name, ts_json_name) if name}
    allowed_names = set(expected_paths) | set(expected_artifacts) | infrastructure
    for n in names:
        if n not in allowed_names:
            print('  ! PLIK SPOZA ZAPIECZETOWANEGO MANIFESTU: %s' % n)
            extra += 1

    # Lancuch zaufania: manifest -> pieczec -> znacznik czasu
    print('\n== Lancuch zaufania ==')
    seal_hash = None
    signature_ok = False
    issuer_ok = False
    key_status = None
    if seal_name:
        try:
            seal = strict_json_loads(read(seal_name).decode('utf-8'))
            seal_hash = (seal.get('canonical_manifest_sha256') or '').strip().lower()
            ed = seal.get('ed25519') if isinstance(seal.get('ed25519'), dict) else {}
            public_key = (ed.get('public_key') or '').strip()
            key_status = TRUSTED_AKTA_KEYS_B64.get(public_key)
            issuer_ok = key_status in ('current', 'retired')
            signature_ok = ed25519_verify(
                canonical_bytes(manifest), ed.get('signature') or '', public_key,
            )
            if public_key:
                calculated_kid = hashlib.sha256(base64.b64decode(public_key, validate=True)).hexdigest()[:16]
                if ed.get('key_id') and ed.get('key_id') != calculated_kid:
                    signature_ok = False
        except Exception:
            pass
    recomputed = canonical_sha256(manifest)
    hash_ok = bool(seal_hash) and recomputed == seal_hash
    seal_ok = bool(hash_ok and signature_ok and issuer_ok)
    if seal_hash:
        print('  %s Hash pieczeci zgadza sie z manifestem (%s...).' % ('OK ' if hash_ok else 'ZLE', recomputed[:16]))
        if not hash_ok:
            print('      manifest przeliczony: %s\n      w pieczeci:           %s' % (recomputed, seal_hash))
        print('  %s Podpis Ed25519 manifestu.' % ('OK ' if signature_ok else 'ZLE'))
        print('  %s Klucz wystawcy Akta (%s).' % ('OK ' if issuer_ok else 'ZLE', key_status or 'nierozpoznany'))
    else:
        print('  Brak 08_MANIFEST_SEAL.json — nie mozna powiazac manifestu z pieczecia.')

    ts_ok = None
    if (tsr_name or ts_json_name) and tsr_name and ts_json_name and seal_hash:
        token = read(tsr_name)
        try:
            ts_metadata = strict_json_loads(read(ts_json_name).decode('utf-8'))
            metadata_ok = isinstance(ts_metadata, dict) and ts_metadata.get('data_sha256') == seal_hash
        except Exception:
            metadata_ok = False
        if token[:1] == b'\x30' and any(mi in token for mi in message_imprints(seal_hash)) and metadata_ok:
            ts_ok = True
            print('  OK  Znacznik czasu RFC 3161 obejmuje hash pieczeci/manifestu.')
            print('      Niezalezna kontrola podpisu TSA i lancucha zaufania:')
            print('      openssl ts -verify -digest %s -in %s -CAfile <cert_TSA.pem>' % (seal_hash, tsr_name.split('/')[-1]))
        else:
            ts_ok = False
            print('  UWAGA: znacznik 13_TIMESTAMP.tsr NIE obejmuje hasha manifestu — sprawdz recznie.')
    elif tsr_name or ts_json_name:
        ts_ok = False
        print('  UWAGA: niepelny albo niepowiazany zestaw znacznika czasu 13_TIMESTAMP.*.')
    else:
        print('  Brak 13_TIMESTAMP.tsr — paczka bez zaufanego znacznika czasu (hashe i pieczec nadal weryfikowalne).')

    print('\n== Podsumowanie ==')
    print('  Pliki: %d OK, %d zly hash, %d brak, %d spoza manifestu.' % (ok, bad, missing, extra))
    print('  Artefakty: %d OK, %d niezgodne, %d brak.' % (artifact_ok, artifact_bad, artifact_missing))
    print('  Pieczec: %s' % ('OK' if seal_ok else 'NIE potwierdzona'))
    if ts_ok is not None:
        print('  Znacznik czasu (powiazanie): %s' % ('OK' if ts_ok else 'NIE potwierdzony'))
    failed = duplicate_names or bad or missing or extra or artifact_bad or artifact_missing or artifact_manifest_error or evidence_manifest_error or document_seal_error or contract_error or not seal_ok or (ts_ok is False)
    print('\n  WYNIK: %s' % ('WSZYSTKO ZGODNE' if not failed else 'WYKRYTO ROZBIEZNOSCI — patrz wyzej'))
    sys.exit(0 if not failed else 1)


if __name__ == '__main__':
    main()
