#!/usr/bin/env python3
"""GewapeSign offline verifier: checks a document against its proof file WITHOUT contacting Gewape.

    python3 verify.py signed.pdf proof.json [--ca gewapesign-tsa-ca.pem] [--esplora https://blockstream.info/api]

Checks, in order (each reported as PASS / FAIL / PENDING / SKIPPED):
  1. fingerprint   SHA-256 of the file equals a fingerprint in the proof (signed or original)
  2. commitment    SHA-256(salt || fingerprint) equals the published commitment
  3. timestamp     RFC 3161 token covers the commitment and its signature verifies against the TSA certificate
                   (with `cryptography` + `asn1crypto` installed, else with the `openssl` command)
  4. merkle        the commitment's Merkle path leads to the batch root
  5. anchor        the OpenTimestamps proof starts at the commitment, passes through the batch root, and ends in a
                   Bitcoin block attestation; prints the block height and the merkle root that block must have.
                   Compare it with any block explorer or `bitcoin-cli getblockheader`, or pass --esplora URL to let
                   this script ask an explorer you choose (only the block height is sent). `ots verify` (the
                   reference client) also works on the .ots file from proof.zip.
Exit status: 0 when every check that can run passes (a pending anchor is reported, not failed), 1 on any failure.
Standard library only; `cryptography`/`asn1crypto` are optional. Wording: the proof makes changes detectable
(tamper-evident) and is independently verifiable; it does not stop anyone editing a copy of the file.
"""
import argparse
import base64
import hashlib
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.request

MAGIC = b"\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94"
TAG_PENDING = bytes.fromhex("83dfe30d2ef90c8e")
TAG_BITCOIN = bytes.fromhex("0588960d73d71901")
OID_TIMESTAMPING = "1.3.6.1.5.5.7.3.8"


# ---------------------------------------------------------------- Merkle (same rules as app/evidence_merkle.py)
def leaf(c):
    return hashlib.sha256(b"\x00" + c).digest()


def node(left, right):
    return hashlib.sha256(b"\x01" + left + right).digest()


def fold(c, path):
    h = leaf(c)
    for step in path:
        sib = bytes.fromhex(step["hash"])
        h = node(h, sib) if step["side"] == "R" else node(sib, h)
    return h


# ---------------------------------------------------------------- minimal OpenTimestamps reader
class OtsError(ValueError):
    pass


class R:
    def __init__(self, b):
        self.f = io.BytesIO(b)

    def read(self, n):
        b = self.f.read(n)
        if len(b) != n:
            raise OtsError("truncated proof")
        return b

    def byte(self):
        return self.read(1)[0]

    def varuint(self):
        n = shift = 0
        while True:
            b = self.byte()
            n |= (b & 0x7f) << shift
            if not b & 0x80:
                return n
            shift += 7
            if shift > 63:
                raise OtsError("bad varuint")

    def varbytes(self, maxlen=8192):
        n = self.varuint()
        if n > maxlen:
            raise OtsError("field too long")
        return self.read(n)


def _apply(tag, arg, msg):
    if tag == 0x08:
        return hashlib.sha256(msg).digest()
    if tag == 0x02:
        return hashlib.sha1(msg).digest()
    if tag == 0x03:
        try:
            return hashlib.new("ripemd160", msg).digest()
        except ValueError:
            raise OtsError("ripemd160 unavailable in this Python; use `ots verify`")
    if tag == 0xf0:
        return msg + arg
    if tag == 0xf1:
        return arg + msg
    if tag == 0xf2:
        return msg[::-1]
    if tag == 0xf3:
        return msg.hex().encode()
    raise OtsError("unsupported op 0x%02x" % tag)


def ots_walk(r, msg, out, depth=0):
    """Parse a timestamp, recording every message reached and every attestation (msg, tag, payload)."""
    if depth > 256:
        raise OtsError("proof too deep")
    out["msgs"].add(msg)

    def item(tag):
        if tag == 0x00:
            out["att"].append((msg, r.read(8), r.varbytes()))
        elif tag in (0xf0, 0xf1):
            arg = r.varbytes(4096)
            ots_walk(r, _apply(tag, arg, msg), out, depth + 1)
        elif tag in (0x08, 0x02, 0x03, 0xf2, 0xf3):
            ots_walk(r, _apply(tag, b"", msg), out, depth + 1)
        else:
            raise OtsError("unknown tag 0x%02x" % tag)

    tag = r.byte()
    while tag == 0xff:
        item(r.byte())
        tag = r.byte()
    item(tag)


def parse_ots(data):
    r = R(data)
    if r.read(len(MAGIC)) != MAGIC:
        raise OtsError("not an OpenTimestamps proof")
    if r.varuint() != 1:
        raise OtsError("unsupported version")
    tag = r.byte()
    size = {0x08: 32, 0x02: 20, 0x03: 20}.get(tag)
    if not size:
        raise OtsError("unsupported file hash")
    digest = r.read(size)
    out = {"msgs": set(), "att": []}
    ots_walk(r, digest, out)
    if r.f.read(1):
        raise OtsError("trailing bytes")
    btc, pending = [], []
    for msg, tag, payload in out["att"]:
        if tag == TAG_BITCOIN:
            btc.append((R(payload).varuint(), msg[::-1].hex()))
        elif tag == TAG_PENDING:
            pending.append(R(payload).varbytes(1000).decode("ascii", "replace"))
    return digest, out["msgs"], sorted(btc), sorted(set(pending))


# ---------------------------------------------------------------- RFC 3161
def verify_tsa_python(token, digest, chain_pem, trusted_ca_pem):
    from asn1crypto import cms, pem as apem, tsp
    from cryptography import x509
    from cryptography.exceptions import InvalidSignature
    from cryptography.hazmat.primitives import hashes
    from cryptography.hazmat.primitives.asymmetric import ec, padding, rsa

    def check(pub, sig, data, h):
        if isinstance(pub, rsa.RSAPublicKey):
            pub.verify(sig, data, padding.PKCS1v15(), h)
        elif isinstance(pub, ec.EllipticCurvePublicKey):
            pub.verify(sig, data, ec.ECDSA(h))
        else:
            raise ValueError("unsupported key type")

    sd = cms.ContentInfo.load(token)["content"]
    econtent = sd["encap_content_info"]["content"].contents
    tst = tsp.TSTInfo.load(econtent)
    if tst["message_imprint"]["hashed_message"].native != digest:
        raise ValueError("token does not cover this commitment")
    si = sd["signer_infos"][0]
    sid = si["sid"].chosen
    certs = [c.chosen for c in sd["certificates"]]
    signer = next(c for c in certs if c.issuer == sid["issuer"] and c.serial_number == sid["serial_number"].native)
    attrs = {a["type"].native: a["values"][0] for a in si["signed_attrs"]}
    if attrs["message_digest"].native != hashlib.sha256(econtent).digest():
        raise ValueError("message digest attribute mismatch")
    ess = attrs.get("signing_certificate_v2")
    if ess is None or ess["certs"][0]["cert_hash"].native != hashlib.sha256(signer.dump()).digest():
        raise ValueError("signing-certificate attribute mismatch")
    cc = x509.load_der_x509_certificate(signer.dump())
    eku = cc.extensions.get_extension_for_class(x509.ExtendedKeyUsage)
    if not eku.critical or [o.dotted_string for o in eku.value] != [OID_TIMESTAMPING]:
        raise ValueError("signer is not a time-stamping certificate")
    try:
        check(cc.public_key(), si["signature"].native, si["signed_attrs"].untag().dump(), hashes.SHA256())
    except InvalidSignature:
        raise ValueError("token signature does not verify")
    gen = tst["gen_time"].native
    anchors = [x509.load_der_x509_certificate(d) for _, _, d in apem.unarmor(trusted_ca_pem.encode(), multiple=True)]
    for ca in anchors:
        if ca.subject == cc.issuer:
            try:
                check(ca.public_key(), cc.signature, cc.tbs_certificate_bytes, cc.signature_hash_algorithm)
                if not (cc.not_valid_before_utc <= gen <= cc.not_valid_after_utc):
                    raise ValueError("token time outside the TSA certificate validity")
                return gen.isoformat(), "signed by " + cc.subject.rfc4514_string()
            except InvalidSignature:
                pass
    raise ValueError("TSA certificate is not issued by the trusted CA")


def verify_tsa_openssl(token, digest, chain_pem, trusted_ca_pem):
    exe = shutil.which("openssl")
    if not exe:
        raise RuntimeError("neither cryptography+asn1crypto nor openssl is available")
    with tempfile.TemporaryDirectory() as d:
        paths = {}
        for name, data in (("t.der", token), ("ca.pem", trusted_ca_pem.encode()), ("tsa.pem", chain_pem[0].encode())):
            paths[name] = os.path.join(d, name)
            with open(paths[name], "wb") as f:
                f.write(data)
        p = subprocess.run([exe, "ts", "-verify", "-digest", digest.hex(), "-token_in", "-in", paths["t.der"],
                            "-CAfile", paths["ca.pem"], "-untrusted", paths["tsa.pem"]], capture_output=True, text=True)
        if "Verification: OK" not in p.stdout + p.stderr:
            raise ValueError("openssl ts -verify failed: " + (p.stdout + p.stderr).strip().splitlines()[-1])
    return None, "signature checked with openssl"


def ca_fingerprint(pem_text):
    body = "".join(l for l in pem_text.splitlines() if l and not l.startswith("-----"))
    return hashlib.sha256(base64.b64decode(body)).hexdigest()


# ---------------------------------------------------------------- main
def main(argv=None):
    ap = argparse.ArgumentParser(description="Verify a document against its GewapeSign proof, offline.")
    ap.add_argument("document")
    ap.add_argument("proof")
    ap.add_argument("--ca", help="trusted TSA CA certificate (PEM) published by Gewape; default: the CA inside the proof")
    ap.add_argument("--esplora", help="Esplora-compatible explorer URL of YOUR choice to check the Bitcoin block")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    a = ap.parse_args(argv)
    res = []

    def rep(check, status, detail=""):
        res.append({"check": check, "status": status, "detail": detail})
        if not a.json:
            print("%-12s %-8s %s" % (check, status, detail))

    with open(a.document, "rb") as f:
        sha = hashlib.sha256(f.read()).hexdigest()
    with open(a.proof) as f:
        proof = json.load(f)
    if proof.get("format") != "gewapesign-proof/1":
        rep("format", "FAIL", "not a gewapesign-proof/1 file")
        return finish(a, res)
    fp = next((x for x in proof.get("fingerprints", []) if x.get("sha256") == sha), None)
    if fp is None:
        rep("fingerprint", "FAIL", "SHA-256 %s matches no fingerprint in this proof: the file differs from the one "
                                   "recorded (changed, re-saved or a different document)" % sha)
        return finish(a, res)
    rep("fingerprint", "PASS", "%s document, SHA-256 %s" % (fp["kind"], sha))

    c = hashlib.sha256(bytes.fromhex(fp["salt"]) + bytes.fromhex(sha)).digest()
    if c.hex() != fp["commitment"]:
        rep("commitment", "FAIL", "SHA-256(salt || fingerprint) != commitment")
        return finish(a, res)
    rep("commitment", "PASS", c.hex())

    ts = fp.get("timestamp") or {}
    if not ts.get("token"):
        rep("timestamp", "PENDING", "no RFC 3161 token issued yet")
    else:
        chain = ts.get("tsa_chain") or []
        if a.ca:
            with open(a.ca) as f:
                trusted = f.read()
            src = "pinned CA " + a.ca
        else:
            trusted = "".join(chain[1:]) or "".join(chain)
            src = "CA from the proof (compare its fingerprint with the one Gewape publishes)"
        try:
            token = base64.b64decode(ts["token"])
            try:
                gen, who = verify_tsa_python(token, c, chain, trusted)
            except ImportError:
                gen, who = verify_tsa_openssl(token, c, chain, trusted)
            rep("timestamp", "PASS", "time %s, %s; trust: %s; CA SHA-256 %s" % (gen or ts.get("gen_time"), who, src,
                                                                           ca_fingerprint(trusted) if trusted else "?"))
        except Exception as e:
            rep("timestamp", "FAIL", str(e))

    m = fp.get("merkle")
    if not m:
        rep("merkle", "PENDING", "not yet in an anchoring batch")
    else:
        root = fold(c, m["path"]).hex()
        if root != m["root"]:
            rep("merkle", "FAIL", "Merkle path does not lead to the batch root")
        else:
            rep("merkle", "PASS", "batch root %s" % root)

    an = fp.get("anchor") or {}
    if not an.get("ots_proof"):
        rep("anchor", "PENDING", "anchoring pending (no calendar proof yet)")
    else:
        try:
            digest, msgs, btc, pend = parse_ots(base64.b64decode(an["ots_proof"]))
            if digest != c:
                raise OtsError("the OpenTimestamps proof is for a different commitment")
            if m and bytes.fromhex(m["root"]) not in msgs:
                raise OtsError("the OpenTimestamps proof does not pass through the batch root")
            if not btc:
                rep("anchor", "PENDING", "submitted to %s; waiting for a Bitcoin block (re-download the proof later)"
                    % ", ".join(pend or ["calendars"]))
            else:
                height, mroot = btc[0]
                detail = "Bitcoin block %d must have merkle root %s" % (height, mroot)
                if an.get("txid"):
                    detail += " (transaction %s)" % an["txid"]
                if a.esplora:
                    base = a.esplora.rstrip("/")
                    bh = urllib.request.urlopen("%s/block-height/%d" % (base, height), timeout=20).read().decode().strip()
                    blk = json.loads(urllib.request.urlopen("%s/block/%s" % (base, bh), timeout=20).read())
                    if blk.get("merkle_root") != mroot:
                        rep("anchor", "FAIL", "block %d merkle root is %s, proof says %s" % (height, blk.get("merkle_root"), mroot))
                        return finish(a, res)
                    rep("anchor", "PASS", detail + "; confirmed by %s, block time %s" % (base, blk.get("timestamp")))
                else:
                    rep("anchor", "PASS", detail + "; check that root on any explorer (or use --esplora)")
        except Exception as e:
            rep("anchor", "FAIL", str(e))
    return finish(a, res)


def finish(a, res):
    failed = any(r["status"] == "FAIL" for r in res)
    if a.json:
        print(json.dumps({"result": "mismatch" if failed else "match", "checks": res}, indent=2))
    else:
        print("\nRESULT: %s" % ("MISMATCH: do not rely on this document/proof pair" if failed else
                                "MATCH: the file is the one recorded; every available check passed"))
    return 1 if failed else 0


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