#!/usr/bin/env python3
"""Vérifier une preuve AIMailBox (preuve-<n>.zip), sans AIMailBox.

Autonome : bibliothèque standard, `cryptography` pour la signature DKIM, et
`openssl` pour les jetons d'horodatage s'il est installé. Il ne fait confiance
ni au serveur qui a produit la preuve, ni à ce programme au-delà de ce qu'il
affiche : chaque contrôle se refait à la main avec les fichiers du dossier.

    pip install "cryptography>=44"
    python verifier.py preuve-123.zip        # ou le dossier décompressé

Contrôles, dans l'ordre :
  1. message.eml a bien l'empreinte SHA-256 relevée à sa conservation ;
  2. chaque fichier de pieces-jointes/ est extrait, octet pour octet, de ce
     message — et porte l'empreinte annoncée ;
  3. l'origine : la signature DKIM du domaine expéditeur est revérifiée, hors
     ligne, avec la clé publique relevée dans le DNS le jour de la
     conservation (origine.json) ;
  4. les jetons d'horodatage (RFC 3161) du message et de l'origine, par
     `openssl ts -verify` ; avec `renouvellements.json`, la chaîne de leurs
     renouvellements (jetons d'archive en arbre, façon RFC 4998) : chaque jeton
     se vérifie à la date du jeton qui le couvre, le dernier à la date du jour.

    python verifier.py preuve-123.zip --a-la-date 2038-02-01
        vérifie comme à cette date (ce qu'en dirait un vérificateur en 2038).

Code de sortie 0 si tout est valide, 1 sinon.

Ce fichier sert aussi AIMailBox lui-même : les pièces jointes et la
vérification DKIM d'une preuve sont calculées par ces mêmes fonctions.
"""

from __future__ import annotations

import argparse
import base64
import binascii
import hashlib
import io
import json
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from datetime import UTC, datetime
from email import message_from_bytes, policy
from email.utils import getaddresses
from pathlib import Path

try:
    from cryptography.exceptions import InvalidSignature
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import padding
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
    from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
except ImportError:  # le contrôle DKIM le dira ; le reste se vérifie sans
    Ed25519PublicKey = None

DOSSIER_PJ = "pieces-jointes"
# Lot D, D5 : le message tel que le serveur l'a stocké, chiffré avec la clé au repos de la boîte.
CHIFFRE_RECU = "message-recu.pgp"


# --- pièces jointes -----------------------------------------------------------------
# Marques de sens d'écriture : « facture\u202efdp.exe » s'afficherait « factureexe.pdf ».
_BIDI = "[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]"


def _nom_sur(nom: str | None, pris: set[str], ancien: bool = False) -> str:
    """Un nom de fichier sans chemin ni caractère interdit, unique dans le dossier.

    `ancien` : le nom d'avant le 2026-10-03, qui gardait les marques bidi —
    celui des dossiers déjà constitués.
    """
    nom = (nom or "").replace("\\", "/").split("/")[-1]
    if not ancien:
        nom = re.sub(_BIDI, "", nom)
    nom = re.sub(r'[\x00-\x1f<>:"|?*]', "_", nom).strip(" .")[:150] or "piece"
    base, point, ext = nom.rpartition(".")
    if not point or not base:
        base, ext = nom, ""
    candidat, n = nom, 2
    while candidat.lower() in pris:
        candidat = f"{base} ({n}){'.' + ext if ext else ''}"
        n += 1
    pris.add(candidat.lower())
    return candidat


def pieces_jointes(octets: bytes, ancien: bool = False) -> list[dict]:
    """Les pièces jointes du message, dans l'ordre : nom, type, taille, sha256, contenu.

    Mêmes pièces que la lecture du message dans AIMailBox (disposition
    « attachment »). `index` est leur rang dans le parcours du message.
    """
    message = message_from_bytes(octets, policy=policy.default)
    rendu, pris = [], set()
    for index, part in enumerate(message.walk()):
        if part.get_content_disposition() != "attachment":
            continue
        if part.get_content_type() == "message/rfc822":
            contenu = part.get_payload(0).as_bytes() if part.get_payload() else b""
            nom = part.get_filename() or f"message-joint-{index}.eml"
        else:
            contenu = part.get_payload(decode=True) or b""
            nom = part.get_filename() or f"piece-{index}"
        rendu.append({"index": index, "nom": _nom_sur(nom, pris, ancien), "type": part.get_content_type(),
                      "taille": len(contenu), "sha256": hashlib.sha256(contenu).hexdigest(),
                      "contenu": contenu})
    return rendu


# --- DKIM (RFC 6376, RFC 8463) -------------------------------------------------------
def _crlf(octets: bytes) -> bytes:
    return octets if b"\r\n" in octets else octets.replace(b"\n", b"\r\n")


def _separer(octets: bytes) -> tuple[list[bytes], bytes]:
    """Les en-têtes bruts (repliés, CRLF final compris) et le corps."""
    octets = _crlf(octets)
    fin = octets.find(b"\r\n\r\n")
    tete, corps = (octets, b"") if fin < 0 else (octets[:fin + 2], octets[fin + 4:])
    entetes: list[bytes] = []
    for ligne in tete.split(b"\r\n")[:-1] if tete.endswith(b"\r\n") else tete.split(b"\r\n"):
        if ligne[:1] in (b" ", b"\t") and entetes:
            entetes[-1] += ligne + b"\r\n"
        else:
            entetes.append(ligne + b"\r\n")
    return entetes, corps


def _nom(entete: bytes) -> str:
    return entete.split(b":", 1)[0].strip().decode("ascii", "replace").lower()


def etiquettes(valeur: str) -> dict[str, str]:
    """Les `clé=valeur` d'une signature DKIM ou d'un enregistrement DNS."""
    rendu = {}
    for morceau in valeur.split(";"):
        cle, egal, val = morceau.partition("=")
        if egal:
            rendu[cle.strip()] = val.strip()
    return rendu


def _sans_blancs(valeur: str) -> str:
    return re.sub(r"\s+", "", valeur)


def _corps_canonique(corps: bytes, mode: str) -> bytes:
    if mode == "relaxed":
        lignes = [re.sub(rb"[ \t]+", b" ", ligne).rstrip(b" ") for ligne in corps.split(b"\r\n")]
        corps = b"\r\n".join(lignes)
        while corps.endswith(b"\r\n"):
            corps = corps[:-2]
        return corps + b"\r\n" if corps else b""
    while corps.endswith(b"\r\n\r\n"):
        corps = corps[:-2]
    return corps if corps.endswith(b"\r\n") else corps + b"\r\n"


def _entete_canonique(entete: bytes, mode: str) -> bytes:
    if mode != "relaxed":
        return entete
    nom, _, valeur = entete.partition(b":")
    valeur = re.sub(rb"[ \t]+", b" ", valeur.replace(b"\r\n", b"")).strip(b" ")
    return nom.rstrip(b" \t").lower() + b":" + valeur + b"\r\n"


def signatures_dkim(octets: bytes) -> list[dict]:
    """Les signatures DKIM du message, du haut vers le bas, avec leurs étiquettes."""
    entetes, _ = _separer(octets)
    rendu = []
    for rang, entete in enumerate(entetes):
        if _nom(entete) != "dkim-signature":
            continue
        valeur = entete.split(b":", 1)[1].decode("utf-8", "replace")
        tags = etiquettes(valeur)
        rendu.append({"rang": rang, "d": tags.get("d", "").lower(), "s": tags.get("s", ""),
                      "a": tags.get("a", "").lower(), "c": tags.get("c", "simple/simple").lower(),
                      "h": [h.strip().lower() for h in _sans_blancs(tags.get("h", "")).split(":") if h.strip()],
                      "i": tags.get("i", ""), "t": tags.get("t", ""), "l": tags.get("l", ""),
                      "tags": tags})
    return rendu


def nom_de_cle(signature: dict) -> str:
    return f"{signature['s']}._domainkey.{signature['d']}"


def _cle_rsa(der: bytes):
    try:
        return serialization.load_der_public_key(der)
    except ValueError:
        # Clé au format PKCS#1 (RSAPublicKey nue) : on l'enveloppe en SPKI.
        def tlv(tag, contenu):
            n = len(contenu)
            longueur = bytes([n]) if n < 128 else bytes([0x80 | ((n.bit_length() + 7) // 8)]) + n.to_bytes(
                (n.bit_length() + 7) // 8, "big")
            return bytes([tag]) + longueur + contenu
        algo = tlv(0x30, bytes.fromhex("06092a864886f70d0101010500"))
        return serialization.load_der_public_key(tlv(0x30, algo + tlv(0x03, b"\x00" + der)))


def verifier_signature(octets: bytes, signature: dict, cle_txt: str | None) -> dict:
    """Vérifie UNE signature DKIM avec l'enregistrement DNS donné (texte TXT, ou None)."""
    rendu = {"d": signature["d"], "s": signature["s"], "a": signature["a"], "c": signature["c"],
             "cle": nom_de_cle(signature)}
    if Ed25519PublicKey is None:
        return {**rendu, "resultat": "non-verifiable", "motif": "bibliothèque cryptography absente"}
    if cle_txt is None:
        return {**rendu, "resultat": "cle-absente", "motif": "aucune clé publiée sous ce nom"}
    cle_tags = etiquettes(cle_txt)
    p = _sans_blancs(cle_tags.get("p", ""))
    if not p:
        return {**rendu, "resultat": "cle-revoquee", "motif": "clé retirée par le domaine (p= vide)"}
    algo = signature["a"]
    if algo not in ("rsa-sha256", "rsa-sha1", "ed25519-sha256"):
        return {**rendu, "resultat": "non-verifiable", "motif": f"algorithme {algo or 'absent'} non pris en charge"}
    entetes, corps = _separer(octets)
    c_entetes, _, c_corps = signature["c"].partition("/")
    c_corps = c_corps or "simple"
    tags = signature["tags"]
    corps_c = _corps_canonique(corps, c_corps)
    if signature["l"]:
        try:
            corps_c = corps_c[:int(signature["l"])]
        except ValueError:
            return {**rendu, "resultat": "invalide", "motif": "étiquette l= illisible"}
    empreinte = hashlib.sha1 if algo == "rsa-sha1" else hashlib.sha256
    if base64.b64encode(empreinte(corps_c).digest()).decode() != _sans_blancs(tags.get("bh", "")):
        return {**rendu, "resultat": "invalide", "motif": "le corps du message ne correspond plus à la signature"}

    # En-têtes signés : pour chaque nom de h=, la dernière occurrence pas encore prise.
    donnees, pris = b"", set()
    for nom in signature["h"]:
        for rang in range(len(entetes) - 1, -1, -1):
            if rang not in pris and rang != signature["rang"] and _nom(entetes[rang]) == nom:
                pris.add(rang)
                donnees += _entete_canonique(entetes[rang], c_entetes)
                break
    propre = entetes[signature["rang"]]
    nom_s, _, valeur_s = propre.partition(b":")
    valeur_s = re.sub(rb"((?:^|;)[ \t\r\n]*b[ \t\r\n]*=)[^;]*", rb"\1", valeur_s)
    donnees += _entete_canonique(nom_s + b":" + valeur_s, c_entetes).removesuffix(b"\r\n")

    try:
        brute = base64.b64decode(_sans_blancs(tags.get("b", "")))
        cle = base64.b64decode(p)
        if algo == "ed25519-sha256":
            Ed25519PublicKey.from_public_bytes(cle).verify(brute, hashlib.sha256(donnees).digest())
        else:
            publique = _cle_rsa(cle)
            if not isinstance(publique, RSAPublicKey):
                raise ValueError("clé publiée qui n'est pas RSA")
            publique.verify(brute, donnees, padding.PKCS1v15(),
                            hashes.SHA1() if algo == "rsa-sha1" else hashes.SHA256())  # noqa: S303
    except InvalidSignature:
        return {**rendu, "resultat": "invalide", "motif": "signature des en-têtes invalide"}
    except (ValueError, TypeError) as exc:
        return {**rendu, "resultat": "invalide", "motif": f"clé ou signature illisible ({exc})"}
    return {**rendu, "resultat": "valide", "motif": ""}


def domaine_expediteur(octets: bytes) -> str:
    message = message_from_bytes(octets, policy=policy.compat32)
    adresses = getaddresses([str(v) for v in message.get_all("From", [])])
    adresse = adresses[0][1] if adresses else ""
    return adresse.rpartition("@")[2].strip().strip(">").lower()


def aligne(domaine_signataire: str, domaine: str) -> bool:
    """Le signataire est-il le domaine de l'expéditeur, ou l'un de ses parents ou sous-domaines ?"""
    d, f = domaine_signataire.lower().strip("."), domaine.lower().strip(".")
    return bool(d and f) and (d == f or f.endswith("." + d) or d.endswith("." + f))


def verdict(resultats: list[dict], domaine: str) -> str:
    valides = [r for r in resultats if r["resultat"] == "valide"]
    if any(aligne(r["d"], domaine) for r in valides):
        return "authentifie"
    if valides:
        return "signe-par-un-tiers"
    if not resultats:
        return "non-signe"
    if all(r["resultat"] in ("cle-absente", "cle-revoquee", "non-verifiable") for r in resultats):
        return "non-verifiable"
    return "invalide"


VERDICTS = {
    "authentifie": "le domaine de l'expéditeur a signé ce message (DKIM)",
    "signe-par-un-tiers": "message signé, mais par un autre domaine que celui de l'expéditeur "
                          "(prestataire d'envoi, liste de diffusion…)",
    "non-signe": "le message ne porte aucune signature DKIM",
    "non-verifiable": "signature présente, mais sa clé n'était plus publiée",
    "invalide": "la signature ne correspond pas au message",
}


def verifier_origine(octets: bytes, cles: dict[str, str | None]) -> dict:
    """Toutes les signatures du message, vérifiées avec ces clés (nom DNS → texte TXT)."""
    domaine = domaine_expediteur(octets)
    resultats = []
    for signature in signatures_dkim(octets)[:5]:
        r = verifier_signature(octets, signature, cles.get(nom_de_cle(signature)))
        resultats.append({**r, "aligne": aligne(r["d"], domaine)})
    return {"domaine_expediteur": domaine, "signatures": resultats, "verdict": verdict(resultats, domaine)}


# --- vérification d'un dossier de preuve --------------------------------------------
class Rapport:
    def __init__(self):
        self.erreurs = 0

    def ok(self, texte):
        print(f"  ✓ {texte}")

    def ko(self, texte):
        self.erreurs += 1
        print(f"  ✗ {texte}")

    def info(self, texte):
        print(f"  · {texte}")


def _lire(chemin: Path) -> dict[str, bytes]:
    if chemin.is_dir():
        return {str(f.relative_to(chemin)).replace("\\", "/"): f.read_bytes() for f in chemin.rglob("*") if f.is_file()}
    with zipfile.ZipFile(chemin) as z:
        return {nom: z.read(nom) for nom in z.namelist() if not nom.endswith("/")}


# Les racines d'autorités d'horodatage qu'une preuve AIMailBox peut joindre, épinglées par
# l'empreinte SHA-256 de leur certificat (DER). Une racine jointe qui n'est pas ici n'entre
# PAS dans la vérification : sinon, qui fabrique une fausse autorité nommée « Sectigo » et
# joint sa racine obtient « valide » (relecture d'agt-revue, 2026-10-03). Celles du magasin
# du système restent acceptées. Relevées le 2026-10-03 ; R46 autosignée recoupée : même clé
# publique que la R46 signée par USERTrust qui figure dans les jetons de Sectigo.
RACINES_CONNUES = {
    "4941b001b8a97e961b7817c9d9e960ec4b056bfc915a8c1aabf6ef6b3ac046a5": "Sectigo Public Time Stamping Root R46",
    "e793c9b02fd8aa13e21c31228accb08119643b749c898964b1746d46c3d4cbd2": "USERTrust RSA Certification Authority (Sectigo)",
    "552f7bdcf1a7af9e6ce672017f4f12abf77240c78e761ac203d1d9d20ac89988": "DigiCert Trusted Root G4",
    "a6379e7cecc05faa3cbf076013d745e327bbbaa38c0b9af22469d4701d18aabc": "FreeTSA Root CA",
}
RACINES_SYSTEME = ("/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem")
_PEM = re.compile(rb"-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----", re.S)


def empreinte_pem(pem: bytes) -> str:
    """SHA-256 (hex) du certificat DER contenu dans un bloc PEM."""
    corps = b"".join(pem.strip().splitlines()[1:-1])
    return hashlib.sha256(base64.b64decode(corps)).hexdigest()


def racines_admises(r: Rapport, pem: bytes | None, quoi: str) -> bytes:
    """Les racines jointes que l'on reconnaît ; chaque inconnue est un échec, dit tel quel."""
    admises = []
    for bloc in _PEM.findall(pem or b""):
        try:
            empreinte = empreinte_pem(bloc)
        except (ValueError, binascii.Error):
            r.ko(f"racine jointe illisible pour le jeton {quoi}")
            continue
        if empreinte in RACINES_CONNUES:
            admises.append(bloc)
        else:
            r.ko(f"racine jointe inconnue pour le jeton {quoi} (sha256 {empreinte[:16]}…) : elle n'est pas "
                 "celle d'une autorité reconnue ; recoupez-la avec celle que publie l'autorité")
    return b"\n".join(admises)


def _racines(admises: bytes) -> bytes:
    """Les racines jointes reconnues, puis celles du système."""
    morceaux = [admises] if admises else []
    for chemin in RACINES_SYSTEME:
        if Path(chemin).exists():
            morceaux.append(Path(chemin).read_bytes())
            break
    return b"\n".join(morceaux)


def _openssl(*args: str, entree: bytes | None = None) -> subprocess.CompletedProcess:
    return subprocess.run([shutil.which("openssl"), *args], input=entree, capture_output=True, check=False)  # noqa: S603


def _cn(texte: str) -> str:
    trouve = re.search(r"CN ?= ?([^,/\n]+)", texte)
    return (trouve.group(1) if trouve else texte).strip()


def _dn(texte: str) -> str:
    """Un nom distinctif comparable d'une sortie openssl à l'autre (espaces retirés)."""
    return re.sub(r"\s+", "", texte)


def signataire(jeton: bytes) -> dict:
    """Le certificat qui vérifie RÉELLEMENT la signature du jeton (`openssl cms -verify
    -signer`), son numéro et son émetteur. La liste des certificats du jeton n'est pas
    signée : un certificat ajouté de même numéro, ou un autre à l'usage horodatage placé
    en premier, n'y change rien (relectures d'agt-revue de 4e0b0bb et 8261d9d).
    {numero, emetteur, pem, certs} ; numero None si la signature ne se vérifie pas."""
    with tempfile.TemporaryDirectory() as tmp:
        t = Path(tmp)
        (t / "j.tsr").write_bytes(jeton)
        _openssl("ts", "-reply", "-in", str(t / "j.tsr"), "-token_out", "-out", str(t / "j.tok"))
        certs = _openssl("pkcs7", "-inform", "DER", "-in", str(t / "j.tok"), "-print_certs").stdout
        signe = _openssl("cms", "-verify", "-inform", "DER", "-in", str(t / "j.tok"), "-noverify", "-binary",
                         "-signer", str(t / "s.pem"), "-out", str(t / "contenu"))
        if signe.returncode != 0 or not (t / "s.pem").exists():
            return {"numero": None, "emetteur": "", "pem": None, "certs": certs}
        pem = (t / "s.pem").read_bytes()
        champs = _openssl("x509", "-in", str(t / "s.pem"), "-noout", "-serial", "-issuer").stdout.decode(
            "utf-8", "replace")
    numero = re.search(r"serial=([0-9A-Fa-f]+)", champs)
    emetteur = re.search(r"issuer=(.+)", champs)
    return {"numero": int(numero.group(1), 16) if numero else None,
            "emetteur": emetteur.group(1).strip() if emetteur else "", "pem": pem, "certs": certs}


def signataire_et_racine(jeton: bytes, racines: bytes) -> tuple[str, str]:
    """(nom du signataire, nom de la racine qui a servi), pour l'affichage. Une chaîne qui
    ne se vérifie pas jusqu'à une racine admise : racine « ? »."""
    qui = signataire(jeton)
    if not qui["pem"]:
        return "?", "?"
    with tempfile.TemporaryDirectory() as tmp:
        t = Path(tmp)
        (t / "s.pem").write_bytes(qui["pem"])
        (t / "certs.pem").write_bytes(qui["certs"])
        (t / "racines.pem").write_bytes(racines)
        sujet = _openssl("x509", "-in", str(t / "s.pem"), "-noout", "-subject").stdout.decode("utf-8", "replace")
        chaine = _openssl("verify", "-show_chain", "-no_check_time", "-CAfile", str(t / "racines.pem"),
                          "-untrusted", str(t / "certs.pem"), str(t / "s.pem"))
    nom = _cn(sujet.split("=", 1)[-1])
    if chaine.returncode != 0:
        return nom, "?"
    profondeurs = re.findall(r"^depth=\d+: (.+?)(?: \(untrusted\))?$", chaine.stdout.decode("utf-8", "replace"), re.M)
    return nom, (_cn(profondeurs[-1]) if profondeurs else "?")


def date_du_jeton(jeton: bytes) -> datetime | None:
    """La date que porte un jeton RFC 3161 (genTime)."""
    with tempfile.TemporaryDirectory() as tmp:
        (Path(tmp) / "j.tsr").write_bytes(jeton)
        texte = _openssl("ts", "-reply", "-in", str(Path(tmp) / "j.tsr"), "-text").stdout.decode("utf-8", "replace")
    trouve = re.search(r"Time stamp: (\w{3}\s+\d+ \d{2}:\d{2}:\d{2})(?:\.\d+)? (\d{4}) GMT", texte)
    if not trouve:
        return None
    return datetime.strptime(" ".join(f"{trouve.group(1)} {trouve.group(2)}".split()),
                             "%b %d %H:%M:%S %Y").replace(tzinfo=UTC)


def verifier_jeton(jeton: bytes, *, donnees: bytes | None = None, empreinte: bytes | None = None,
                   racines: bytes = b"", a_la_date: datetime | None = None) -> tuple[bool, str]:
    """`openssl ts -verify` sur des données ou sur une empreinte SHA-256, la chaîne de
    certificats jugée à `a_la_date` (-attime), à défaut maintenant. (ok, cause)."""
    with tempfile.TemporaryDirectory() as tmp:
        t = Path(tmp)
        (t / "jeton.tsr").write_bytes(jeton)
        (t / "racines.pem").write_bytes(racines)
        commande = ["ts", "-verify", "-in", str(t / "jeton.tsr"), "-CAfile", str(t / "racines.pem")]
        if donnees is not None:
            (t / "donnees").write_bytes(donnees)
            commande += ["-data", str(t / "donnees")]
        else:
            commande += ["-digest", empreinte.hex()]
        if a_la_date is not None:
            commande += ["-attime", str(int(a_la_date.timestamp()))]
        sortie = _openssl(*commande)
    texte = (sortie.stdout + sortie.stderr).decode("utf-8", "replace").strip()
    cause = re.findall(r"Verify error:(.+)", texte)
    return sortie.returncode == 0, (cause[-1] if cause else texte[-200:]).strip()


def _jeton(r: Rapport, jeton: bytes, donnees: bytes, quoi: str, racines: bytes | None,
           a_la_date: datetime | None = None) -> None:
    if not shutil.which("openssl"):
        r.info(f"jeton {quoi} présent ; openssl absent, vérifiez-le à la main (voir LISEZMOI.txt)")
        return
    confiance = _racines(racines_admises(r, racines, quoi))
    ok, cause = verifier_jeton(jeton, donnees=donnees, racines=confiance, a_la_date=a_la_date)
    if not ok:
        r.ko(f"jeton {quoi} invalide : {cause}")
        return
    moment = date_du_jeton(jeton)
    nom, racine = signataire_et_racine(jeton, confiance)
    r.ok(f"jeton {quoi} valide{f', daté du {moment:%Y-%m-%d %H:%M} UTC' if moment else ''}"
         f" — signé par « {nom} », racine « {racine} »")


# --- renouvellements (RFC 4998, ERS) ---------------------------------------------------
#
# Un jeton d'archive horodate la RACINE d'un arbre de Merkle dont les feuilles sont les
# jetons à protéger ; renouveler, c'est horodater (dans un nouvel arbre) le jeton
# d'archive précédent et ses données de validation, avant que son certificat expire.
# Le nouveau prouve que l'ancien existait quand son certificat était valide : chaque
# jeton se vérifie à la date de celui qui le couvre, le dernier à la date demandée.
# Règle de RFC 4998 §4 : empreintes triées (binaire) avant concaténation.

def feuille(empreintes: list[bytes]) -> bytes:
    """SHA-256 des empreintes triées puis concaténées."""
    return hashlib.sha256(b"".join(sorted(empreintes))).digest()


def remonter(depart: bytes, chemin: list[str]) -> bytes:
    """De la feuille à la racine : à chaque niveau, le frère (hex) et le nœud, triés."""
    noeud = depart
    for frere in chemin:
        noeud = feuille([noeud, bytes.fromhex(frere)])
    return noeud


def elements_de_validation(validation: dict | None) -> list[bytes]:
    """Ce qu'un renouvellement fige pour un signataire : ses certificats (PEM) et CRL (DER)."""
    valide = validation or {}
    return ([c.encode() for c in valide.get("certificats", [])]
            + [base64.b64decode(c, validate=True) for c in valide.get("crl", [])])


def feuille_de_jeton(jeton: bytes, couvert: list[bytes], validation: dict | None) -> bytes:
    """La feuille d'un jeton : ce qu'il couvre (les données, pour le jeton initial), le jeton,
    et les données de validation de son signataire."""
    return feuille([hashlib.sha256(x).digest() for x in [*couvert, jeton, *elements_de_validation(validation)]])


def revocation(r: Rapport, jeton: bytes, validation: dict | None, nom: str) -> bool:
    """Le signataire de `jeton` figure-t-il dans les CRL figées de son émetteur ? Jamais muet :
    pas de CRL → dit ; signataire introuvable, CRL illisible → échec. True si rien ne s'oppose."""
    crls = (validation or {}).get("crl") or []
    if not crls:
        r.info(f"{nom} : aucune donnée de révocation figée pour son signataire")
        return True
    qui = signataire(jeton)
    if qui["numero"] is None:
        r.ko(f"{nom} : signataire introuvable dans le jeton, sa révocation ne peut pas être contrôlée")
        return False
    de_l_emetteur = 0
    with tempfile.TemporaryDirectory() as tmp:
        for i, crl in enumerate(crls):
            (Path(tmp) / f"{i}.crl").write_bytes(base64.b64decode(crl, validate=True))
            sortie = _openssl("crl", "-inform", "DER", "-in", str(Path(tmp) / f"{i}.crl"), "-noout", "-text")
            texte = sortie.stdout.decode("utf-8", "replace")
            emetteur = re.search(r"Issuer: ([^\n]+)", texte)
            if sortie.returncode != 0 or not emetteur:
                r.ko(f"{nom} : liste de révocation figée illisible")
                return False
            # Un numéro n'est unique que chez un émetteur : seule sa CRL compte.
            if _dn(emetteur.group(1)) != _dn(qui["emetteur"]):
                continue
            de_l_emetteur += 1
            revoques = {int(n, 16) for n in re.findall(r"Serial Number: ([0-9A-Fa-f]+)", texte)}
            if qui["numero"] in revoques:
                r.ko(f"{nom} : son signataire figure dans la liste de révocation figée au renouvellement")
                return False
    if not de_l_emetteur:
        r.info(f"{nom} : aucune liste de révocation de l'émetteur de son signataire")
    return True


def verifier_chaine(r: Rapport, renouv: dict, quoi: str, libelle: str, donnees: bytes, jeton: bytes,
                    racines: bytes | None, a_la_date: datetime | None) -> None:
    """Le jeton initial de `quoi` (message|origine), puis chaque archive qui le couvre."""
    entree = (renouv.get("feuilles") or {}).get(quoi) if isinstance(renouv, dict) else None
    if not entree:
        _jeton(r, jeton, donnees, libelle, racines, a_la_date)
        return
    if not shutil.which("openssl"):
        r.info(f"jeton {libelle} et ses renouvellements présents ; openssl absent, vérifiez-les à la main")
        return
    try:
        _suivre(r, renouv, entree, libelle, donnees, jeton, racines, a_la_date or datetime.now(UTC))
    except (KeyError, TypeError, ValueError, AttributeError, binascii.Error) as exc:
        r.ko(f"renouvellements.json illisible pour le jeton {libelle} : {type(exc).__name__} {exc}")


def _suivre(r: Rapport, renouv: dict, entree: dict, libelle: str, donnees: bytes, jeton: bytes,
            racines: bytes | None, maintenant: datetime) -> None:
    if renouv.get("algorithme", "sha256") != "sha256":
        r.ko(f"renouvellements : algorithme {renouv.get('algorithme')!r} inconnu de ce vérificateur")
        return
    archives = renouv.get("archives") or {}
    nom, chemin = entree["archive"], entree.get("chemin") or []

    # Les racines jointes à la preuve, examinées UNE fois (une inconnue = un seul échec) ;
    # celles de chaque archive passent par le même épinglage, une fois par archive.
    de_la_preuve = racines_admises(r, racines, libelle)
    examinees: dict[str, bytes] = {}

    def confiance(nom_archive: str, archive: dict) -> bytes:
        if nom_archive not in examinees:
            examinees[nom_archive] = racines_admises(
                r, b"\n".join(c.encode() for c in archive.get("racines", [])), f"de l'archive {nom_archive}")
        return _racines(b"\n".join(x for x in (de_la_preuve, examinees[nom_archive]) if x))

    def autorite(jeton_x: bytes, conf: bytes) -> str:
        # Le signataire réel et sa racine, pas le champ « par » du JSON (texte libre).
        qui, racine = signataire_et_racine(jeton_x, conf)
        return f"signé par « {qui} », racine « {racine} »"

    def date_de(archive: dict | None) -> datetime | None:
        return date_du_jeton(base64.b64decode(archive["jeton"], validate=True)) if archive else None

    date_jeton = date_du_jeton(jeton)
    premiere = archives.get(nom)
    t0 = date_de(premiere)
    if date_jeton is None or t0 is None:
        r.ko(f"jeton {libelle} ou sa première archive : date illisible")
        return
    # Le jeton initial, à la date de la première archive qui le couvre, puis sa révocation.
    conf0 = confiance(nom, premiere)
    ok, cause = verifier_jeton(jeton, donnees=donnees, racines=conf0, a_la_date=t0)
    if not ok:
        r.ko(f"jeton {libelle} invalide à la date de son premier renouvellement ({t0:%Y-%m-%d}) : {cause}")
        return
    if not revocation(r, jeton, entree.get("validation"), f"jeton {libelle}"):
        return
    r.ok(f"jeton {libelle} valide, daté du {date_jeton:%Y-%m-%d} (vérifié au {t0:%Y-%m-%d}, "
         f"date de son premier renouvellement) — {autorite(jeton, conf0)}")
    courant = feuille_de_jeton(jeton, [donnees], entree.get("validation"))
    precedente, vues = date_jeton, set()
    while True:
        if nom in vues or nom not in archives:
            r.ko(f"renouvellements : archive {nom!r} {'en boucle' if nom in vues else 'absente'}")
            return
        vues.add(nom)
        archive = archives[nom]
        jeton_a = base64.b64decode(archive["jeton"], validate=True)
        date_a = date_du_jeton(jeton_a)
        if date_a is None or date_a < precedente:
            r.ko(f"renouvellement {nom} : date illisible ou antérieure au jeton qu'il couvre")
            return
        suite = archive.get("couverte_par")
        suivante = archives.get(suite["archive"]) if suite else None
        if suite and suivante is None:
            r.ko(f"renouvellement {nom} : l'archive qui le couvre ({suite['archive']!r}) est absente")
            return
        juge = date_de(suivante) if suite else maintenant
        if suite and juge is None:
            r.ko(f"renouvellement {nom} : l'archive qui le couvre a une date illisible")
            return
        if not suite and maintenant < date_a:
            r.info(f"renouvellement {nom} daté du {date_a:%Y-%m-%d}, après la date demandée "
                   f"({maintenant:%Y-%m-%d}) : il n'existait pas encore")
        conf = confiance(nom, archive)
        ok, cause = verifier_jeton(jeton_a, empreinte=remonter(courant, chemin), racines=conf, a_la_date=juge)
        qui = f"renouvelé le {date_a:%Y-%m-%d}"
        if not ok:
            r.ko(f"renouvellement {nom} ({qui}) : le message n'est pas dans cet arbre, ou jeton invalide "
                 f"au {juge:%Y-%m-%d} : {cause}")
            return
        if not suite:
            r.ok(f"{qui} : vérifiable au {juge:%Y-%m-%d} — {autorite(jeton_a, conf)}")
            return
        # Contrôler d'abord, afficher ensuite (relecture d'agt-revue de 4e0b0bb).
        if not revocation(r, jeton_a, archive.get("validation"), f"renouvellement {nom}"):
            return
        r.ok(f"{qui} : valide au {juge:%Y-%m-%d}, date du renouvellement suivant — {autorite(jeton_a, conf)}")
        courant = feuille_de_jeton(jeton_a, [], archive.get("validation"))
        precedente = date_a
        nom, chemin = suite["archive"], suite.get("chemin") or []


def verifier(chemin: str, a_la_date: datetime | None = None) -> int:
    fichiers = _lire(Path(chemin))
    r = Rapport()
    # Un dossier de litige range chaque pièce dans son sous-dossier.
    racines_pieces = sorted({n.rsplit("/", 1)[0] + "/" if "/" in n else "" for n in fichiers
                             if n.rsplit("/", 1)[-1] == "message.eml" and f"{DOSSIER_PJ}/" not in n})
    for prefixe in racines_pieces:
        if len(racines_pieces) > 1 or prefixe:
            print(f"\n=== {prefixe.rstrip('/') or 'message'} ===")
        _verifier_une(r, {n[len(prefixe):]: v for n, v in fichiers.items() if n.startswith(prefixe)}, a_la_date)
    if not racines_pieces:
        r.ko("aucun message.eml dans ce dossier")
    print()
    print("RÉSULTAT :", "tout est valide." if not r.erreurs else f"{r.erreurs} contrôle{'s' if r.erreurs > 1 else ''} en échec.")
    return 0 if not r.erreurs else 1


def _verifier_le_chiffre(r: Rapport, f: dict[str, bytes], reception: dict) -> None:
    """Le chiffré stocké a l'empreinte qu'inscrit l'entrée « reception » du journal des preuves. Le relier
    au message demande la clé au repos de la boîte et un outil OpenPGP qui lit ce format (Sequoia)."""
    if CHIFFRE_RECU not in f or not reception.get("b3"):
        return
    try:
        import blake3
    except ImportError:
        r.info(f"{CHIFFRE_RECU} : empreinte BLAKE3 non recalculée (`pip install blake3`)")
        return
    if blake3.blake3(f[CHIFFRE_RECU]).hexdigest() != reception["b3"]:
        r.ko(f"{CHIFFRE_RECU} ne correspond pas à l'empreinte annoncée")
        return
    # n1 d'agt-revue : le numéro vient de preuve.json, pas d'un extrait du journal contrôlé ici.
    entree = (f", annoncée par l'entrée n° {reception['entree']} du journal des preuves (à recouper avec le journal)"
              if reception.get("entree") else "")
    r.ok(f"{CHIFFRE_RECU} : empreinte BLAKE3 {reception['b3'][:16]}…{entree}")


def _verifier_une(r: Rapport, f: dict[str, bytes], a_la_date: datetime | None = None) -> None:
    octets = f["message.eml"]
    fiche = json.loads(f["preuve.json"]) if "preuve.json" in f else {}
    racine = next((v for n, v in f.items() if n.startswith("autorite-") and n.endswith(".pem")), None)

    print("1. Le message")
    attendue = f.get("empreinte.sha256", b"").decode().split()[0] if f.get("empreinte.sha256") else ""
    actuelle = hashlib.sha256(octets).hexdigest()
    if attendue and attendue == actuelle:
        r.ok(f"message.eml identique à celui conservé (sha256 {actuelle[:16]}…)")
    else:
        r.ko(f"message.eml ne correspond pas à l'empreinte annoncée ({attendue[:16]}… ≠ {actuelle[:16]}…)")
    _verifier_le_chiffre(r, f, fiche.get("reception") or {})

    print("2. Les pièces jointes")
    extraites = pieces_jointes(octets)
    # Un dossier constitué avant le 2026-10-03 nomme ses fichiers à l'ancienne.
    anciens = [p["nom"] for p in pieces_jointes(octets, ancien=True)]
    annoncees = {p["nom"]: p for p in fiche.get("pieces_jointes", [])}
    if not extraites:
        r.info("ce message n'a pas de pièce jointe")
    for i, pj in enumerate(extraites):
        cle = pj["nom"] if f"{DOSSIER_PJ}/{pj['nom']}" in f else anciens[i]
        fichier = f.get(f"{DOSSIER_PJ}/{cle}")
        if fichier is None:
            r.ko(f"{pj['nom']} : absente du dossier")
        elif fichier != pj["contenu"]:
            r.ko(f"{pj['nom']} : ne correspond PAS à la pièce jointe du message")
        elif cle in annoncees and annoncees[cle]["sha256"] != pj["sha256"]:
            r.ko(f"{pj['nom']} : empreinte annoncée erronée")
        else:
            r.ok(f"{pj['nom']} ({pj['taille']} octets, sha256 {pj['sha256'][:16]}…) extraite du message")

    print("3. L'origine (DKIM)")
    if "origine.json" not in f:
        r.info("origine non relevée pour ce message")
    else:
        origine = json.loads(f["origine.json"])
        cles = {nom: v.get("txt") for nom, v in origine.get("cles_dns", {}).items()}
        refait = verifier_origine(octets, cles)
        r.info(f"clés relevées dans le DNS le {origine.get('capturee_le', '?')[:16].replace('T', ' à ')} UTC")
        for s in refait["signatures"]:
            texte = (f"signature de {s['d']} (sélecteur {s['s']}, {s['a']}) : {s['resultat']}"
                     f"{' — ' + s['motif'] if s['motif'] else ''}{'' if s['aligne'] else ' — domaine différent de l’expéditeur'}")
            (r.ok if s["resultat"] == "valide" else r.info)(texte)
        if refait["verdict"] != origine.get("verdict"):
            r.ko(f"verdict recalculé « {refait['verdict']} » ≠ verdict annoncé « {origine.get('verdict')} »")
        elif refait["verdict"] == "authentifie":
            r.ok(f"origine : {VERDICTS['authentifie']} ({refait['domaine_expediteur']})")
        else:
            r.info(f"origine : {VERDICTS.get(refait['verdict'], refait['verdict'])}")

    print("4. Horodatage")
    if a_la_date:
        r.info(f"vérification comme au {a_la_date:%Y-%m-%d}")
    try:
        renouv = json.loads(f["renouvellements.json"]) if "renouvellements.json" in f else {}
    except ValueError as exc:
        r.ko(f"renouvellements.json illisible : {exc}")
        renouv = {}
    if "horodatage.tsr" in f:
        verifier_chaine(r, renouv, "message", "du message", octets, f["horodatage.tsr"], racine, a_la_date)
    else:
        r.info("pas de jeton d'horodatage pour le message")
    if "origine.tsr" in f and "origine.json" in f:
        racine_o = next((v for n, v in f.items() if n == "autorite-origine.pem"), None)
        verifier_chaine(r, renouv, "origine", "de l'origine", f["origine.json"], f["origine.tsr"], racine_o,
                        a_la_date)


def main(argv=None):
    parser = argparse.ArgumentParser(description="Vérifier une preuve AIMailBox")
    parser.add_argument("preuve", help="le fichier preuve-<n>.zip, ou le dossier décompressé")
    parser.add_argument("--a-la-date", metavar="AAAA-MM-JJ",
                        help="vérifier comme à cette date (certificats jugés à cette date)")
    args = parser.parse_args(argv)
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") \
        if hasattr(sys.stdout, "buffer") else sys.stdout
    quand = datetime.fromisoformat(args.a_la_date).replace(tzinfo=UTC) if args.a_la_date else None
    return verifier(args.preuve, quand)


if __name__ == "__main__":
    sys.exit(main())
