#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Vérificateur indépendant des paquets de preuve « Preuve d'achats ».

Ce programme refait, SANS le service et sans rien installer (Python 3.8 ou plus, bibliothèque standard),
tous les calculs qui prouvent qu'un fichier de facture n'a pas changé depuis son dépôt :

  1. l'empreinte SHA-256 de chaque fichier, et sa « feuille » SHA-256(aléa || fichier) ;
  2. le chemin de Merkle de chaque feuille jusqu'à la racine du jour ;
  3. la racine dans le journal public, et le chaînage de tout le journal ;
  4. la signature Ed25519 du reçu remis au dépôt (algorithme de la RFC 8032, écrit ci-dessous) ;
  5. le contenu normalisé (méthode PA-NORM-1) : son empreinte, et, si pdftotext est installé, qu'il
     redonne bien le texte du PDF ;
  6. le jeton d'horodatage RFC 3161 (par openssl, s'il est installé) ;
  7. la preuve OpenTimestamps : qu'elle part bien de la racine, et ce qu'elle atteste.
     La confirmation finale dans Bitcoin se fait par le client officiel :  ots verify -d <racine> ancrage.ots

Usage :
  python3 verifier.py paquet.zip            vérifie un paquet (ZIP ou dossier décompressé)
  python3 verifier.py normaliser ligne-index.json facture.pdf
                                             calcule l'empreinte du contenu normalisé d'une pièce
                                             (pour comparer deux téléchargements de la même facture)
Code de retour : 0 si tout est juste, 1 si un contrôle échoue, 2 si l'appel est mal formé.
La méthode complète est écrite dans METHODE.md (dossier docs/ du projet) ; la notice est LISEZ-MOI.txt.
"""
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unicodedata
import zipfile

VERSION = "1.0"

# ---------------------------------------------------------------------------------------------------
# Affichage
# ---------------------------------------------------------------------------------------------------
ECHECS = []
AVERTISSEMENTS = []


def ok(msg):
    print("  [JUSTE]  " + msg)


def echec(msg):
    ECHECS.append(msg)
    print("  [FAUX]   " + msg)


def avertir(msg):
    AVERTISSEMENTS.append(msg)
    print("  [NOTE]   " + msg)


def sha256_hex(b):
    return hashlib.sha256(b).hexdigest()


# ---------------------------------------------------------------------------------------------------
# JSON canonique (même règle que le serveur) : clés triées, aucun espace, nombres à la JavaScript
# ---------------------------------------------------------------------------------------------------
def canon(v):
    if v is None:
        return "null"
    if v is True:
        return "true"
    if v is False:
        return "false"
    if isinstance(v, int):
        return str(v)
    if isinstance(v, float):
        if v != v or v in (float("inf"), float("-inf")):
            raise ValueError("nombre non représentable")
        if v.is_integer() and abs(v) < 1e21:
            return str(int(v))
        return repr(v)
    if isinstance(v, str):
        return json.dumps(v, ensure_ascii=False)
    if isinstance(v, list):
        return "[" + ",".join(canon(x) for x in v) + "]"
    if isinstance(v, dict):
        return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canon(v[k]) for k in sorted(v)) + "}"
    raise ValueError("type non représentable : %r" % type(v))


# ---------------------------------------------------------------------------------------------------
# PA-NORM-1 : normalisation du texte sorti de pdftotext
# ---------------------------------------------------------------------------------------------------
BLANCS = re.compile("[\t\v \u00a0\u1680\u2000-\u200b\u2028\u2029\u202f\u205f\u3000\ufeff]+")


def normaliser_texte(brut):
    t = unicodedata.normalize("NFC", brut)
    t = t.replace("\r\n", "\n").replace("\r", "\n").replace("\f", "\n")
    lignes = []
    for l in t.split("\n"):
        l = BLANCS.sub(" ", l).strip(" ")
        if l:
            lignes.append(l)
    return "\n".join(lignes)


def texte_pdf(pdf_octets):
    """Texte du PDF par pdftotext (Poppler). Rend None si pdftotext n'est pas installé."""
    if not shutil.which("pdftotext"):
        return None
    with tempfile.TemporaryDirectory() as d:
        f = os.path.join(d, "f.pdf")
        with open(f, "wb") as h:
            h.write(pdf_octets)
        r = subprocess.run(["pdftotext", "-enc", "UTF-8", "-eol", "unix", f, "-"], capture_output=True)
        if r.returncode != 0:
            return None
        return r.stdout.decode("utf-8")


def contenu_normalise(index_obj, pdf_octets):
    brut = texte_pdf(pdf_octets)
    if brut is None:
        return None
    return canon({"index": index_obj, "methode": "PA-NORM-1", "texte_pdf": normaliser_texte(brut)}).encode("utf-8")


def pdf_de(role, octets):
    if role == "pdf":
        return octets
    with zipfile.ZipFile(io.BytesIO(octets)) as z:
        pdfs = [n for n in z.namelist() if n.lower().endswith(".pdf")]
        if len(pdfs) != 1:
            raise ValueError("ZIP de PDF : %d PDF" % len(pdfs))
        return z.read(pdfs[0])


# ---------------------------------------------------------------------------------------------------
# Merkle (même règle que le serveur) : feuille 0x00, nœud 0x01, nœud seul monte tel quel
# ---------------------------------------------------------------------------------------------------
def racine_depuis_chemin(feuille_hex, chemin):
    n = hashlib.sha256(b"\x00" + bytes.fromhex(feuille_hex)).digest()
    for e in chemin:
        frere = bytes.fromhex(e["empreinte"])
        if e["cote"] == "G":
            n = hashlib.sha256(b"\x01" + frere + n).digest()
        else:
            n = hashlib.sha256(b"\x01" + n + frere).digest()
    return n.hex()


def chainage(prec_hex, racine_hex):
    prec = bytes.fromhex(prec_hex) if prec_hex else b"\x00" * 32
    return hashlib.sha256(prec + bytes.fromhex(racine_hex)).hexdigest()


# ---------------------------------------------------------------------------------------------------
# Ed25519 (RFC 8032, § 5.1.7), vérification seule, écrite d'après le code de référence de la RFC
# ---------------------------------------------------------------------------------------------------
_P = 2 ** 255 - 19
_L = 2 ** 252 + 27742317777372353535851937790883648493
_D = -121665 * pow(121666, _P - 2, _P) % _P
_I = pow(2, (_P - 1) // 4, _P)


def _inv(x):
    return pow(x, _P - 2, _P)


def _recover_x(y, signe):
    if y >= _P:
        return None
    x2 = (y * y - 1) * _inv(_D * y * y + 1)
    if x2 % _P == 0:
        return None if signe else 0
    x = pow(x2, (_P + 3) // 8, _P)
    if (x * x - x2) % _P != 0:
        x = x * _I % _P
    if (x * x - x2) % _P != 0:
        return None
    if (x & 1) != signe:
        x = _P - x
    return x


def _add(P, Q):
    A = (P[1] - P[0]) * (Q[1] - Q[0]) % _P
    B = (P[1] + P[0]) * (Q[1] + Q[0]) % _P
    C = 2 * P[3] * Q[3] * _D % _P
    D = 2 * P[2] * Q[2] % _P
    E, F, G, H = B - A, D - C, D + C, B + A
    return (E * F % _P, G * H % _P, F * G % _P, E * H % _P)


def _mul(s, P):
    Q = (0, 1, 1, 0)
    while s > 0:
        if s & 1:
            Q = _add(Q, P)
        P = _add(P, P)
        s >>= 1
    return Q


def _egal(P, Q):
    return (P[0] * Q[2] - Q[0] * P[2]) % _P == 0 and (P[1] * Q[2] - Q[1] * P[2]) % _P == 0


_GY = 4 * _inv(5) % _P
_GX = _recover_x(_GY, 0)
_G = (_GX, _GY, 1, _GX * _GY % _P)


def _decompresser(s):
    if len(s) != 32:
        return None
    y = int.from_bytes(s, "little")
    signe = y >> 255
    y &= (1 << 255) - 1
    x = _recover_x(y, signe)
    if x is None:
        return None
    return (x, y, 1, x * y % _P)


def ed25519_verifier(publique, message, signature):
    if len(publique) != 32 or len(signature) != 64:
        return False
    A = _decompresser(publique)
    R = _decompresser(signature[:32])
    if A is None or R is None:
        return False
    s = int.from_bytes(signature[32:], "little")
    if s >= _L:
        return False
    h = int.from_bytes(hashlib.sha512(signature[:32] + publique + message).digest(), "little") % _L
    return _egal(_mul(s, _G), _add(R, _mul(h, A)))


# ---------------------------------------------------------------------------------------------------
# OpenTimestamps : lecture du fichier .ots (format officiel), sans réseau
# ---------------------------------------------------------------------------------------------------
MAGIE_OTS = bytes.fromhex("004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294")
ATT_ATTENTE = bytes.fromhex("83dfe30d2ef90c8e")
ATT_BITCOIN = bytes.fromhex("0588960d73d71901")


def lire_ots(b):
    if not b.startswith(MAGIE_OTS):
        raise ValueError("pas un fichier OpenTimestamps")
    pos = [len(MAGIE_OTS)]

    def octet():
        v = b[pos[0]]
        pos[0] += 1
        return v

    def varuint():
        n, m = 0, 1
        while True:
            x = octet()
            n += (x & 0x7F) * m
            m *= 128
            if not x & 0x80:
                return n

    def varbytes():
        l = varuint()
        v = b[pos[0]:pos[0] + l]
        pos[0] += l
        return v

    if varuint() != 1:
        raise ValueError("version OTS inconnue")
    if octet() != 0x08:
        raise ValueError("seule l'empreinte SHA-256 est prise en charge")
    empreinte = b[pos[0]:pos[0] + 32]
    pos[0] += 32
    attestations = []

    def un(msg):
        op = octet()
        if op == 0x00:
            etiq = b[pos[0]:pos[0] + 8]
            pos[0] += 8
            charge = varbytes()
            a = {"engagement": msg.hex()}
            if etiq == ATT_ATTENTE:
                a["type"] = "attente"
                a["calendrier"] = charge[1:1 + charge[0]].decode("utf-8", "replace")
            elif etiq == ATT_BITCOIN:
                n, m, i = 0, 1, 0
                while True:
                    x = charge[i]
                    i += 1
                    n += (x & 0x7F) * m
                    m *= 128
                    if not x & 0x80:
                        break
                a["type"] = "bitcoin"
                a["bloc"] = n
            else:
                a["type"] = "inconnue"
            attestations.append(a)
            return
        if op == 0xF0:
            suivant = msg + varbytes()
        elif op == 0xF1:
            suivant = varbytes() + msg
        elif op == 0x08:
            suivant = hashlib.sha256(msg).digest()
        elif op == 0x02:
            suivant = hashlib.sha1(msg).digest()
        elif op == 0x03:
            suivant = hashlib.new("ripemd160", msg).digest()
        elif op == 0xF2:
            suivant = msg[::-1]
        elif op == 0xF3:
            suivant = msg.hex().encode()
        else:
            raise ValueError("opération OTS inconnue 0x%x" % op)
        tout(suivant)

    def tout(msg):
        while b[pos[0]] == 0xFF:
            pos[0] += 1
            un(msg)
        un(msg)

    tout(empreinte)
    return empreinte.hex(), attestations


# ---------------------------------------------------------------------------------------------------
# Lecture du paquet (ZIP ou dossier)
# ---------------------------------------------------------------------------------------------------
class Paquet:
    def __init__(self, chemin):
        self.zip = None
        self.dossier = None
        if os.path.isdir(chemin):
            self.dossier = chemin
        else:
            self.zip = zipfile.ZipFile(chemin)

    def noms(self):
        if self.zip:
            return [n for n in self.zip.namelist() if not n.endswith("/")]
        sortie = []
        for racine, _, fichiers in os.walk(self.dossier):
            for f in fichiers:
                sortie.append(os.path.relpath(os.path.join(racine, f), self.dossier).replace(os.sep, "/"))
        return sortie

    def lire(self, nom):
        if self.zip:
            return self.zip.read(nom)
        with open(os.path.join(self.dossier, nom), "rb") as h:
            return h.read()

    def existe(self, nom):
        return nom in set(self.noms())

    def json(self, nom):
        return json.loads(self.lire(nom).decode("utf-8"))


def verifier_journal(pq):
    print("\nJournal public des racines")
    journal = pq.json("journal.json")
    prec = None
    for r in journal:
        attendu = chainage(prec, r["racine"])
        if r.get("chainage_precedent") != prec:
            echec("racine %d : chaînage précédent incohérent" % r["numero"])
        if attendu != r["chainage"]:
            echec("racine %d : chaînage faux (le journal a été modifié)" % r["numero"])
        prec = r["chainage"]
    if journal:
        ok("%d racine(s), chaînage juste de la première à la dernière" % len(journal))
    return {r["numero"]: r for r in journal}


def verifier_racine(pq, numero, journal):
    d = "racines/%06d" % numero
    print("\nRacine n° %d" % numero)
    if not pq.existe(d + "/racine.json"):
        echec("racine %d absente du paquet" % numero)
        return
    r = pq.json(d + "/racine.json")
    j = journal.get(numero)
    if not j or j["racine"] != r["racine"] or j["chainage"] != r["chainage"]:
        echec("racine %d : différente de celle du journal" % numero)
    else:
        ok("racine %s… identique au journal" % r["racine"][:16])
    # RFC 3161
    if pq.existe(d + "/horodatage.tsr"):
        if shutil.which("openssl"):
            with tempfile.TemporaryDirectory() as t:
                tsr = os.path.join(t, "h.tsr")
                with open(tsr, "wb") as h:
                    h.write(pq.lire(d + "/horodatage.tsr"))
                chaine = sorted(n for n in pq.noms() if n.startswith(d + "/chaine/") and n.endswith(".pem"))
                ca = [n for n in chaine if "racine" in os.path.basename(n)] or chaine
                autres = [n for n in chaine if n not in ca]
                caf = os.path.join(t, "ca.pem")
                with open(caf, "wb") as h:
                    for n in ca:
                        h.write(pq.lire(n))
                args = ["openssl", "ts", "-verify", "-digest", r["racine"], "-in", tsr, "-CAfile", caf]
                if autres:
                    unt = os.path.join(t, "int.pem")
                    with open(unt, "wb") as h:
                        for n in autres:
                            h.write(pq.lire(n))
                    args += ["-untrusted", unt]
                res = subprocess.run(args, capture_output=True, text=True)
                if "Verification: OK" in (res.stdout + res.stderr):
                    ok("jeton RFC 3161 : juste pour cette racine (openssl ts -verify)")
                else:
                    echec("jeton RFC 3161 : openssl le refuse (%s)" % (res.stdout + res.stderr).strip()[:200])
                txt = subprocess.run(["openssl", "ts", "-reply", "-in", tsr, "-text"], capture_output=True, text=True).stdout
                m = re.search(r"Time stamp: (.+)", txt)
                if m:
                    ok("heure du jeton : " + m.group(1).strip())
                m = re.search(r"TSA: (.+)", txt)
                if m and "FAUX" in m.group(1):
                    avertir("ce jeton vient d'un FAUX horodateur d'essai : il ne prouve rien")
        else:
            avertir("openssl absent : jeton RFC 3161 non vérifié ici (voir LISEZ-MOI.txt)")
    else:
        avertir("pas de jeton RFC 3161 pour cette racine")
    # OpenTimestamps
    if pq.existe(d + "/ancrage.ots"):
        try:
            emp, atts = lire_ots(pq.lire(d + "/ancrage.ots"))
            if emp != r["racine"]:
                echec("ancrage OpenTimestamps : ne part pas de la racine")
            else:
                ok("ancrage OpenTimestamps : part bien de la racine")
            for a in atts:
                if a["type"] == "bitcoin":
                    ok("attesté dans le bloc Bitcoin n° %d (engagement %s…) : à confirmer par « ots verify »" % (a["bloc"], a["engagement"][:16]))
                elif a["type"] == "attente":
                    avertir("ancrage en attente auprès de %s (normal les premières heures)" % a["calendrier"])
                    if "faux" in a["calendrier"]:
                        avertir("ce calendrier est un FAUX d'essai : l'ancrage ne prouve rien")
        except Exception as e:
            echec("fichier OpenTimestamps illisible : %s" % e)
    else:
        avertir("pas d'ancrage OpenTimestamps pour cette racine")


def verifier_capture(pq, dossier, manifeste, racines_vues):
    cap = pq.json(dossier + "/capture.json")
    print("\nPièce %s (%s du %s), reçu n° %s" % (cap["id_facture"], cap.get("type_piece") or "?", cap.get("date_facture") or "?", cap["numero_recu"]))
    par_role = {}
    for f in cap["fichiers"]:
        chemin = dossier + "/" + f["fichier"]
        if not pq.existe(chemin):
            echec("%s : fichier absent du paquet" % f["fichier"])
            continue
        octets = pq.lire(chemin)
        par_role[f["role"]] = octets
        if sha256_hex(octets) != f["sha256"]:
            echec("%s : empreinte SHA-256 fausse (fichier modifié)" % f["fichier"])
            continue
        feuille = hashlib.sha256(bytes.fromhex(f["alea"]) + octets).hexdigest()
        if feuille != f["feuille"]:
            echec("%s : feuille salée fausse" % f["fichier"])
            continue
        if f.get("merkle"):
            m = f["merkle"]
            racines_vues.add(m["racine_numero"])
            d = "racines/%06d/racine.json" % m["racine_numero"]
            r = pq.json(d) if pq.existe(d) else None
            if not r or racine_depuis_chemin(feuille, m["chemin"]) != r["racine"]:
                echec("%s : le chemin de Merkle ne mène pas à la racine n° %d" % (f["fichier"], m["racine_numero"]))
                continue
            ok("%s : empreinte, feuille et chemin justes jusqu'à la racine n° %d" % (f["fichier"], m["racine_numero"]))
        else:
            ok("%s : empreinte et feuille justes" % f["fichier"])
            avertir("%s : pas encore dans une racine (la racine du jour n'était pas fermée)" % f["fichier"])

    # Contenu normalisé
    if "normalise" in par_role:
        n = par_role["normalise"]
        if sha256_hex(n) != cap["empreinte_normalisee"]:
            echec("contenu normalisé : empreinte différente de celle de la capture")
        try:
            obj = json.loads(n.decode("utf-8"))
            if canon(obj).encode("utf-8") != n:
                echec("contenu normalisé : pas sous forme canonique")
            if "index" in par_role and canon(obj["index"]) != canon(json.loads(par_role["index"].decode("utf-8"))):
                echec("contenu normalisé : la ligne d'index ne correspond pas à l'original")
            porteur = "pdf" if "pdf" in par_role else "zip_pdf"
            if porteur in par_role:
                refait = contenu_normalise(json.loads(par_role["index"].decode("utf-8")), pdf_de(porteur, par_role[porteur]))
                if refait is None:
                    avertir("pdftotext absent : le texte normalisé n'a pas été réextrait du PDF ici")
                elif refait == n:
                    ok("contenu normalisé : refait depuis le PDF, identique (empreinte %s…)" % sha256_hex(n)[:16])
                else:
                    avertir("contenu normalisé : le texte réextrait diffère (autre version de Poppler ?) ; l'empreinte déposée reste celle du fichier joint")
        except Exception as e:
            echec("contenu normalisé illisible : %s" % e)

    # Reçu signé
    rc = pq.json(dossier + "/recu.json")
    cle = manifeste["cle_publique_recus"]["hex"]
    if rc.get("cle_publique") != cle:
        echec("reçu : clé publique différente de celle du manifeste")
    if ed25519_verifier(bytes.fromhex(cle), rc["texte"].encode("utf-8"), __import__("base64").b64decode(rc["signature"])):
        ok("reçu : signature Ed25519 juste")
    else:
        echec("reçu : signature fausse")
    recu = json.loads(rc["texte"])
    for fr in recu["fichiers"]:
        f = next((x for x in cap["fichiers"] if x["role"] == fr["role"]), None)
        if not f or f["sha256"] != fr["sha256"] or f["feuille"] != fr["feuille"]:
            echec("reçu : le fichier %s ne correspond pas au reçu" % fr["role"])
    if recu.get("empreinte_normalisee") != cap["empreinte_normalisee"]:
        echec("reçu : empreinte normalisée différente")
    else:
        ok("reçu : mêmes empreintes que les fichiers (reçu le %s)" % recu["recu_le"])


def verifier_paquet(chemin):
    pq = Paquet(chemin)
    manifeste = pq.json("manifeste.json")
    print("%s : vérification du paquet « %s » (vérificateur %s)" % (manifeste.get("service"), manifeste.get("titre"), VERSION))
    if manifeste.get("avertissement"):
        avertir(manifeste["avertissement"])
    journal = verifier_journal(pq)
    racines_vues = set()
    dossiers = sorted({n.rsplit("/", 1)[0] for n in pq.noms() if n.startswith("captures/") and n.endswith("/capture.json")})
    for d in dossiers:
        verifier_capture(pq, d, manifeste, racines_vues)
    for n in sorted(racines_vues):
        verifier_racine(pq, n, journal)
    print("\nBilan : %d contrôle(s) faux, %d note(s)." % (len(ECHECS), len(AVERTISSEMENTS)))
    if ECHECS:
        print("RÉSULTAT : le paquet NE se vérifie PAS.")
        return 1
    print("RÉSULTAT : tous les calculs sont justes.")
    return 0


def main(argv):
    if len(argv) == 2 and argv[1] not in ("-h", "--help"):
        return verifier_paquet(argv[1])
    if len(argv) == 4 and argv[1] == "normaliser":
        with open(argv[2], "rb") as h:
            index = json.loads(h.read().decode("utf-8"))
        with open(argv[3], "rb") as h:
            pdf = h.read()
        n = contenu_normalise(index, pdf)
        if n is None:
            print("pdftotext est nécessaire (paquet poppler-utils).")
            return 2
        print("empreinte des octets du PDF   : " + sha256_hex(pdf))
        print("empreinte du contenu normalisé : " + sha256_hex(n))
        return 0
    print(__doc__)
    return 2


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