#!/usr/bin/env python3
"""Verify a QENEX ICH M7 assessment pack. Offline, standard library only.

    python3 verify_ich_m7_pack.py pack.json

WHY THIS RUNS ON YOUR MACHINE
-----------------------------
You received a document that states acceptable intakes for mutagenic
impurities. The question this answers is narrow and important: is the pack
byte-for-byte the one QENEX issued, or has something been changed since?

Asking QENEX's server that question would mean trusting the issuer to
adjudicate its own document. This script needs no network, no account, and no
third-party packages, so you can read it in full before running it -- it is
deliberately short enough to do that -- and re-run it in five years on an
archived pack when the service may no longer exist.

WHAT IT CHECKS
--------------
1. RECORD INTEGRITY. Every assessment is hashed and compared with the
   record_id it was issued under. record_id IS the SHA-256 of the record's own
   content, so any edit to a structure, an ICH class, an alert or an acceptable
   intake changes the hash and is caught here.

2. SET INTEGRITY. The pack hash covers {batch_id, record_ids, pack_version}.
   That catches a compound being added, removed or reordered after issue --
   which per-record hashes alone cannot detect, because a deleted record takes
   its own hash with it.

Both must pass. Check 1 was added on 2026-08-17; before that a pack whose
acceptable intake had been edited from 1.5 to 120 ug/day still reported as
intact, because nothing compared a record to its own id. If you hold a pack
issued before that date, verify it with THIS script rather than the one that
shipped with it.

WHAT IT DOES NOT CHECK
----------------------
It does not tell you the science is right. It tells you the document is
unaltered. A pack can verify perfectly and still record an assessment that is
incomplete, or one whose statistical (Q)SAR methodology you disagree with --
read status and blocking_reasons for that. Integrity and correctness are
different questions and this script answers only the first.

It also does not, on its own, prove WHO issued the pack. That is the
signature's job. If the pack carries one and you have the `cryptography`
package installed, this script checks it and says so. Without that package the
integrity checks above still run and are still meaningful; the script says
plainly that the signature was not checked rather than passing over it.

EXIT STATUS
-----------
0  every check performed passed
1  a check failed -- the pack is NOT as issued
2  the file could not be read or is not a pack
"""
import hashlib
import json
import sys

# Excluded from a record's hash. record_id is the hash itself, so including it
# would be circular. assessed_utc is excluded so that re-running an identical
# assessment reproduces the identical record rather than a duplicate differing
# only by when it ran.
# Bumped whenever a CHECK changes, not for cosmetics. Packs state the version
# they expect, so someone running an old copy against a new pack is told rather
# than left to assume their download is current.
#
# This exists because the checks themselves changed once already: before
# 1.0.0 only SET integrity was verified, and a pack whose acceptable intake had
# been edited reported as intact. A recipient with that older tool had no way to
# know a stronger check existed.
VERIFIER_VERSION = "1.0.0"

UNHASHED_FIELDS = ("record_id", "assessed_utc")

GREEN, RED, DIM, BOLD, OFF = "\033[32m", "\033[31m", "\033[2m", "\033[1m", "\033[0m"
if not sys.stdout.isatty():
    GREEN = RED = DIM = BOLD = OFF = ""


def canonical(obj) -> bytes:
    """Byte form both sides agree on: sorted keys, no incidental whitespace."""
    return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()


def record_hash(assessment: dict) -> str:
    return hashlib.sha256(
        canonical({k: v for k, v in assessment.items()
                   if k not in UNHASHED_FIELDS})).hexdigest()


def pack_hash(pack: dict) -> str:
    return hashlib.sha256(canonical({
        "batch_id": pack.get("batch_id", ""),
        "record_ids": pack.get("record_ids", []),
        "pack_version": pack.get("pack_version", ""),
    })).hexdigest()


def check_signature(pack: dict):
    """(ok, message). ok is None when the signature could not be checked at all.

    None is a distinct outcome from False on purpose: "not checked" and "checked
    and wrong" must never render the same way to someone deciding whether to
    rely on this document.
    """
    sig = pack.get("pack_signature_json")
    if not sig:
        return None, "pack carries no signature (integrity checks above still apply)"
    try:
        from cryptography.hazmat.primitives.asymmetric.ed25519 import (
            Ed25519PublicKey)
    except ImportError:
        return None, ("not checked -- install the 'cryptography' package to "
                      "verify it: pip install cryptography")
    try:
        s = sig if isinstance(sig, dict) else json.loads(sig)
    except Exception:
        return False, "signature block is not readable JSON"

    algorithm = s.get("algorithm")
    if algorithm != "ed25519":
        # Refuse an unexpected algorithm rather than trying to accommodate it.
        # An attacker who chooses the algorithm chooses the difficulty.
        return False, "unexpected signature algorithm %r (expected ed25519)" % (algorithm,)

    # THE BINDING CHECK, and the reason this is not simply verify(sig, payload).
    #
    # The signature covers s["payload"]. Verifying it proves only that QENEX
    # signed THAT STRING at some point -- not that it describes the pack in
    # front of you. Someone could edit the pack, leave a genuine old signature
    # in place, and a verifier that skipped this step would report a valid
    # signature over a document the signature never covered.
    #
    # So the payload is compared with the hash recomputed from these bytes.
    recomputed = pack_hash(pack)
    if s.get("payload") != recomputed:
        return False, ("signature covers a DIFFERENT pack (signed payload %s, "
                       "this pack hashes to %s)"
                       % ((s.get("payload") or "?")[:16], recomputed[:16]))

    try:
        import base64
        pub = base64.b64decode(s["public_key_b64"])
        raw = base64.b64decode(s["signature_b64"])
        Ed25519PublicKey.from_public_bytes(pub).verify(raw, s["payload"].encode())
    except Exception as exc:
        return False, "SIGNATURE DID NOT VERIFY (%s)" % type(exc).__name__

    signer = pack.get("pack_signer_id") or s.get("signer_id") or "unknown"
    # Naming the key, not just the signer id: the id is a label the pack states
    # about itself, whereas the key is what the maths actually proves. Check it
    # against the key you expect from QENEX out of band.
    return True, "valid -- signer %s, key %s..." % (signer, s["public_key_b64"][:16])


def main(argv) -> int:
    if len(argv) != 2:
        print(__doc__.strip().split("\n\n")[0])
        print("\nusage: python3 %s pack.json" % argv[0])
        return 2
    try:
        with open(argv[1], encoding="utf-8") as fh:
            pack = json.load(fh)
    except Exception as exc:
        print("%scannot read %s: %s%s" % (RED, argv[1], exc, OFF))
        return 2
    if not isinstance(pack, dict) or "assessments" not in pack:
        print("%s%s is not a QENEX ICH M7 assessment pack%s" % (RED, argv[1], OFF))
        return 2

    print("%sQENEX ICH M7 assessment pack%s" % (BOLD, OFF))
    print("  verifier     : %s" % VERIFIER_VERSION)
    print("  batch_id     : %s" % (pack.get("batch_id") or "(unnamed)"))
    print("  pack_version : %s" % (pack.get("pack_version") or "(absent)"))
    print("  compounds    : %d" % len(pack.get("assessments", [])))
    print()

    # Tell the operator BEFORE the checks, not after: a stale verifier that
    # prints three cheerful oks and then mentions its age has already given the
    # wrong impression.
    wanted = (pack.get("how_to_verify") or {}).get("minimum_verifier_version")
    if wanted and wanted != VERIFIER_VERSION:
        print("  %sThis pack expects verifier %s; you are running %s.%s"
              % (RED, wanted, VERIFIER_VERSION, OFF))
        print("  %sGet the current tool: "
              "https://lab.qenex.ai/verify/verify_ich_m7_pack.py%s" % (RED, OFF))
        print("  %sAn older tool may perform FEWER checks and still print ok.%s"
              % (RED, OFF))
        print()

    failures = []

    # ── 1. record integrity ────────────────────────────────────────────────
    altered = []
    for a in pack.get("assessments", []):
        rid = a.get("record_id")
        cid = a.get("compound_id", "(unnamed)")
        if not rid:
            altered.append((cid, "no record_id to check against"))
        elif record_hash(a) != rid:
            altered.append((cid, "content does not hash to its record_id"))
    n = len(pack.get("assessments", []))
    if altered:
        failures.append("records")
        print("  %sRECORD INTEGRITY  FAILED%s  %d of %d altered"
              % (RED, OFF, len(altered), n))
        for cid, why in altered:
            print("      %s%s: %s%s" % (RED, cid, why, OFF))
    else:
        print("  %sRECORD INTEGRITY  ok%s        %d of %d hash to their record_id"
              % (GREEN, OFF, n, n))

    # ── 2. set integrity ───────────────────────────────────────────────────
    stated, recomputed = pack.get("pack_hash"), pack_hash(pack)
    if stated and stated == recomputed:
        print("  %sSET INTEGRITY     ok%s        no compound added, removed or reordered"
              % (GREEN, OFF))
    else:
        failures.append("set")
        print("  %sSET INTEGRITY     FAILED%s" % (RED, OFF))
        print("      stated     %s" % (stated or "(absent)"))
        print("      recomputed %s" % recomputed)

    # ── 3. signature ───────────────────────────────────────────────────────
    ok, msg = check_signature(pack)
    if ok is True:
        print("  %sSIGNATURE         ok%s        %s" % (GREEN, OFF, msg))
    elif ok is False:
        failures.append("signature")
        print("  %sSIGNATURE         FAILED%s    %s" % (RED, OFF, msg))
    else:
        print("  SIGNATURE         %snot checked%s  %s" % (DIM, OFF, msg))

    print()
    if failures:
        print("%sThis pack is NOT as issued (%s). Do not rely on it; ask the "
              "issuer to reissue.%s" % (RED, ", ".join(failures), OFF))
        return 1

    print("%sThis pack is as issued.%s" % (GREEN, OFF))
    print("%sIntegrity only. It does not mean the assessment is complete or that "
          "you agree with it -- read 'status' and 'blocking_reasons' for that.%s"
          % (DIM, OFF))
    return 0


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