#!/usr/bin/env python3
"""
AICVS certificate verifier -- independent, offline, zero-dependency.
==================================================================
Checks an AICVS evidence certificate **without AICVS**. No network, no account,
no vendor. Python 3.8+ standard library only.

Why this exists
---------------
A compliance certificate whose only proof is "the vendor's server says so" is
worth exactly as much as the vendor's continued existence. If AICVS disappears,
every certificate it issued should still be checkable. This script is how.

Copy it, audit it, vendor it into your own repo, run it in ten years. It imports
nothing outside the standard library -- not even for the signature check, which
is RFC 8032 Ed25519 vendored below -- so it stays readable end to end, which is
the point of a tool whose job is to be trusted.

What it proves
--------------
1. **Internal integrity.** Every one of the six chain steps is recomputed from
   the declared inputs. Altering a finding, a score, an article or a filename
   after issue changes a hash, and the chain stops matching.
2. **Binding to real source.** Given the original file (`--source`), it
   recomputes the content digest and confirms the certificate refers to *that
   exact file*, byte for byte.
3. **Origin.** Certificates carry an Ed25519 signature over the terminal digest.
   Checked against a **pinned** public key -- one shipped in this file, or passed
   with `--public-key` -- that establishes AICVS issued it, which the hash chain
   alone cannot show.

The signature check matters most where trust is weakest. A key that travelled
inside the bundle proves only arithmetic: anyone can sign anything with a key
they made up. So a bundle-supplied key is verified and then reported as
UNPINNED, never as proof of origin.

What it does NOT prove
----------------------
- That the findings are *correct*. This checks integrity and origin, not the
  quality of the analysis. A certificate can be intact, genuine, and describe a
  scan that missed something.
- That AICVS issued it, **if the bundle is unsigned or the key is not pinned**.
  Older certificates predate signing and verify on integrity alone.
- Legal certification of anything. AICVS produces readiness support.

Usage
-----
    python aicvs_verify.py bundle.json
    python aicvs_verify.py bundle.json --source screen.py
    python aicvs_verify.py bundle.json --public-key <base64>   # pin explicitly
    python aicvs_verify.py bundle.json --require-signature     # fail if unsigned
    python aicvs_verify.py bundle.json --json                  # machine-readable
    python aicvs_verify.py bundle.json --quiet                 # exit code only

Fetch the published keys once and pin them yourself:

    curl https://api.aicvs.io/.well-known/aicvs-signing-keys.json

Exit codes: 0 verified - 1 verification failed - 2 bad input.

Licence: MIT. Do whatever you want with it -- that is the intent.
"""
from __future__ import annotations

import argparse
import base64
import binascii
import hashlib
import json
import sys

FORMAT = "aicvs-certificate-bundle"
SUPPORTED_FORMAT_VERSIONS = {"1"}

STEP_NAMES = [
    "1_content_ingest",
    "2_identity_binding",
    "3_provenance_findings",
    "4_eu_mapping",
    "5_merkle_seal",
    "6_cert_digest",
]


def sha256(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8")).hexdigest()


def findings_fingerprint(findings: list) -> str:
    """
    Stable digest of the findings.

    Only the four fields that carry meaning are hashed, sorted deterministically.
    Prose (titles, descriptions) is excluded on purpose: reworded copy in a later
    release must not invalidate a certificate issued today, but a changed rule,
    line, severity or score must.
    """
    slim = sorted(
        [
            {
                "rule_id": f.get("rule_id"),
                "line": f.get("line", 0),
                "severity": f.get("severity"),
                "score_impact": f.get("score_impact", 0),
            }
            for f in findings
        ],
        key=lambda x: (x["rule_id"] or "", x["line"] or 0),
    )
    return sha256(json.dumps(slim, sort_keys=True))


def recompute(chain_ver: str, filename: str, scan_id: str, content_sha: str,
              findings: list, eu_articles: list, score: int, status: str) -> dict:
    """
    Re-derive the whole chain. This is the specification: if this function and
    the issuer disagree, one of them is wrong, and both are readable.
    """
    h1 = sha256(f"{chain_ver}|1_content|{filename}|{content_sha}")
    h2 = sha256(f"{chain_ver}|2_identity|{scan_id}|{h1}")
    h3 = sha256(f"{chain_ver}|3_provenance|{findings_fingerprint(findings)}|{h2}")
    h4 = sha256(f"{chain_ver}|4_eu_mapping|{','.join(eu_articles)}|{h3}")
    h5 = sha256(f"{chain_ver}|5_merkle|{h2}|{h4}")
    h6 = sha256(f"{chain_ver}|6_cert|{h5}|{score}|{status}|{filename}")
    return dict(zip(STEP_NAMES, [h1, h2, h3, h4, h5, h6]))


# -----------------------------------------------------------------------------
# Ed25519 verification, RFC 8032, pure standard library.
#
# Vendored rather than imported so this file keeps its only real promise: it
# runs anywhere Python does, with nothing installed, years from now. `pip
# install cryptography` is a dependency on a package index still existing and
# still serving a build for your platform -- which is precisely the assumption
# this tool exists to avoid. Signature verification is a handful of modular
# exponentiations; it does not justify a supply chain.
#
# Verification only. There is no signing here, deliberately.
# -----------------------------------------------------------------------------

_P = 2 ** 255 - 19
_L = 2 ** 252 + 27742317777372353535851937790883648493
_D = -121665 * pow(121666, _P - 2, _P) % _P
_MODP_SQRT_M1 = pow(2, (_P - 1) // 4, _P)


def _sha512_modl(b: bytes) -> int:
    return int.from_bytes(hashlib.sha512(b).digest(), "little") % _L


def _point_add(P, Q):
    """Extended homogeneous coordinates (X, Y, Z, T), per RFC 8032 section 5.1.4."""
    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 _point_mul(s: int, P):
    Q = (0, 1, 1, 0)  # neutral element
    while s > 0:
        if s & 1:
            Q = _point_add(Q, P)
        P = _point_add(P, P)
        s >>= 1
    return Q


def _point_equal(P, Q) -> bool:
    # Projective coordinates: compare cross-multiplied affine values.
    if (P[0] * Q[2] - Q[0] * P[2]) % _P != 0:
        return False
    return (P[1] * Q[2] - Q[1] * P[2]) % _P == 0


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


_G_Y = 4 * pow(5, _P - 2, _P) % _P
_G_X = _recover_x(_G_Y, 0)
_G = (_G_X, _G_Y, 1, _G_X * _G_Y % _P)


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


def ed25519_verify(public_key: bytes, message: bytes, signature: bytes) -> bool:
    """True only if `signature` is a valid Ed25519 signature. Fails closed."""
    try:
        if len(public_key) != 32 or len(signature) != 64:
            return False
        A = _point_decompress(public_key)
        if A is None:
            return False
        Rs = signature[:32]
        R = _point_decompress(Rs)
        if R is None:
            return False
        s = int.from_bytes(signature[32:], "little")
        if s >= _L:                      # non-canonical scalar
            return False
        h = _sha512_modl(Rs + public_key + message)
        return _point_equal(_point_mul(s, _G), _point_add(R, _point_mul(h, A)))
    except Exception:                    # noqa: BLE001 -- any error is a failure
        return False


# Public keys shipped with this verifier. A key here is *pinned*: it did not
# arrive with the bundle, so a signature checked against it establishes origin.
#
# Populate from the published list and commit the value into your own copy:
#     curl https://api.aicvs.io/.well-known/aicvs-signing-keys.json
#
# Retired keys stay listed -- certificates issued under them must keep verifying.
TRUSTED_KEYS: dict = {
    # "0f3a91c4d5e6b708": "base64-encoded 32-byte public key",
}


def signing_payload(chain_version: str, scan_id: str, cert_hash: str) -> bytes:
    """
    Rebuilt independently of the issuer, which is the point: if this string and
    the signer's ever diverge, verification fails rather than passing on trust.
    Domain-separated so a signature cannot be replayed onto another structure.
    """
    return f"aicvs-cert-v1|{chain_version}|{scan_id}|{cert_hash}".encode("utf-8")


class BadBundle(Exception):
    """The file is not a bundle we can check -- distinct from 'it failed'."""


def load_bundle(path: str) -> dict:
    try:
        with open(path, "r", encoding="utf-8") as fh:
            b = json.load(fh)
    except FileNotFoundError:
        raise BadBundle(f"no such file: {path}")
    except json.JSONDecodeError as e:
        raise BadBundle(f"not valid JSON: {e}")

    if b.get("format") != FORMAT:
        raise BadBundle(f"not an AICVS certificate bundle (format={b.get('format')!r})")
    fv = str(b.get("format_version", ""))
    if fv not in SUPPORTED_FORMAT_VERSIONS:
        raise BadBundle(
            f"bundle format version {fv!r} is newer than this verifier understands "
            f"(supports {sorted(SUPPORTED_FORMAT_VERSIONS)}). Refusing to guess."
        )
    for key in ("certificate", "inputs", "evidence_chain", "chain_version"):
        if key not in b:
            raise BadBundle(f"bundle is missing required section: {key!r}")
    return b


def check_signature(bundle: dict, pinned_key_b64: str = "") -> dict:
    """
    Establish origin, or say clearly that it was not established.

    Three outcomes, and the distinction between the last two is the whole
    reason this function is separate from the chain check:

      signed_pinned   -- verified against a key we already held. Origin proven.
      signed_unpinned -- the maths checks out against a key that came with the
                        bundle. Proves nothing about who issued it.
      unsigned        -- no signature. Older certificates predate signing.
    """
    sig = bundle.get("signature")
    cert = bundle.get("certificate", {})
    if not sig or not sig.get("signature"):
        return {"state": "unsigned", "ok": True, "kid": None,
                "detail": "no signature -- integrity only, origin not established"}

    alg = sig.get("algorithm", "")
    if alg != "ed25519":
        return {"state": "unknown_algorithm", "ok": False, "kid": sig.get("kid"),
                "detail": f"unsupported signature algorithm {alg!r}"}

    msg = signing_payload(bundle.get("chain_version", ""),
                          cert.get("scan_id", ""), cert.get("cert_hash", ""))
    kid = sig.get("kid") or ""

    # Order matters: an explicitly pinned key beats anything in the bundle, and
    # our own pinned copy beats the bundle's self-declared key.
    if pinned_key_b64:
        key_b64, pinned = pinned_key_b64, True
    elif kid in TRUSTED_KEYS:
        key_b64, pinned = TRUSTED_KEYS[kid], True
    else:
        key_b64, pinned = sig.get("public_key", ""), False

    if not key_b64:
        return {"state": "no_key", "ok": False, "kid": kid,
                "detail": "signature present but no public key to check it against"}

    try:
        key = base64.b64decode(key_b64, validate=True)
        signature = base64.b64decode(sig["signature"], validate=True)
    except (binascii.Error, ValueError, TypeError):
        return {"state": "malformed", "ok": False, "kid": kid,
                "detail": "signature or key is not valid base64"}

    if not ed25519_verify(key, msg, signature):
        return {"state": "invalid", "ok": False, "kid": kid,
                "detail": "signature does not verify -- this certificate is not authentic"}

    if pinned:
        return {"state": "signed_pinned", "ok": True, "kid": kid,
                "detail": "verified against a pinned key -- origin established"}
    return {"state": "signed_unpinned", "ok": True, "kid": kid,
            "detail": ("verified against the key inside the bundle, which proves "
                       "nothing about origin -- pin the published key to check that")}


def verify(bundle: dict, source_path: str | None = None,
           pinned_key_b64: str = "", require_signature: bool = False) -> dict:
    cert = bundle["certificate"]
    inp = bundle["inputs"]
    chain_ver = bundle["chain_version"]
    checks: list[dict] = []
    failures: list[str] = []

    def check(name: str, ok: bool, detail: str = "") -> bool:
        checks.append({"check": name, "ok": bool(ok), "detail": detail})
        if not ok:
            failures.append(f"{name}: {detail}" if detail else name)
        return bool(ok)

    content_sha = inp.get("content_sha256", "")

    # If the original file is supplied, the certificate must bind to it exactly.
    # This is the check that connects a document to real code.
    if source_path:
        try:
            with open(source_path, "rb") as fh:
                raw = fh.read()
        except OSError as e:
            raise BadBundle(f"could not read --source: {e}")
        # Certificates hash the decoded UTF-8 text, so CRLF/LF differences matter.
        actual = hashlib.sha256(raw).hexdigest()
        check("source file matches certificate", actual == content_sha,
              "" if actual == content_sha
              else f"file digest {actual[:16]}... != certificate {content_sha[:16]}...")

    expected = recompute(
        chain_ver=chain_ver,
        filename=cert.get("filename", ""),
        scan_id=cert.get("scan_id", ""),
        content_sha=content_sha,
        findings=inp.get("findings", []),
        eu_articles=inp.get("eu_articles_triggered", []),
        score=cert.get("score", 0),
        status=cert.get("status", ""),
    )

    # Each recorded step must equal what the inputs produce.
    recorded = {s.get("step"): s.get("hash") for s in bundle["evidence_chain"]}
    for step in STEP_NAMES:
        got, want = recorded.get(step), expected[step]
        if got is None:
            check(f"step {step}", False, "missing from evidence chain")
        else:
            check(f"step {step}", got == want,
                  "" if got == want else f"recorded {got[:16]}... != recomputed {want[:16]}...")

    # And the two published values must be the ones the chain terminates in.
    check("merkle_root matches chain", cert.get("merkle_root") == expected["5_merkle_seal"],
          "" if cert.get("merkle_root") == expected["5_merkle_seal"] else "merkle_root does not match")
    check("cert_hash matches chain", cert.get("cert_hash") == expected["6_cert_digest"],
          "" if cert.get("cert_hash") == expected["6_cert_digest"] else "cert_hash does not match")

    # Origin. Kept last so the chain result is never contingent on it.
    sig = check_signature(bundle, pinned_key_b64)
    check(f"signature ({sig['state']})", sig["ok"], "" if sig["ok"] else sig["detail"])
    if require_signature and sig["state"] != "signed_pinned":
        check("signature is pinned", False,
              f"--require-signature was given but the result was {sig['state']}")

    return {
        "verified": not failures,
        "scan_id": cert.get("scan_id", ""),
        "filename": cert.get("filename", ""),
        "cert_hash": cert.get("cert_hash", ""),
        "chain_version": chain_ver,
        "issued_at": cert.get("created_at", ""),
        "source_checked": bool(source_path),
        "signature": sig,
        "checks": checks,
        "failures": failures,
    }


def report(res: dict) -> None:
    """
    ASCII only, deliberately. Windows consoles still default to cp1252, and a
    verifier that raises UnicodeEncodeError instead of printing its result has
    failed at the one job it has.
    """
    ok = res["verified"]
    print()
    print("  AICVS certificate verification")
    print("  " + "-" * 52)
    print(f"  file        {res['filename']}")
    print(f"  scan id     {res['scan_id']}")
    print(f"  cert hash   {res['cert_hash'][:32]}...")
    print(f"  chain       {res['chain_version']}")
    print(f"  issued      {res['issued_at'] or 'not stated'}")
    print()
    for c in res["checks"]:
        mark = "ok  " if c["ok"] else "FAIL"
        line = f"  [{mark}] {c['check']}"
        print(line + (f"  -- {c['detail']}" if c["detail"] else ""))
    print()
    sig = res["signature"]
    if ok:
        if sig["state"] == "signed_pinned":
            print("  VERIFIED -- the chain is intact and the signature is from AICVS.")
        else:
            print("  VERIFIED -- the chain is internally consistent and unaltered.")
        if sig["state"] == "signed_unpinned":
            print("  Origin NOT established: " + sig["detail"] + ".")
            print("  Pin the published key: curl https://api.aicvs.io/.well-known/aicvs-signing-keys.json")
        elif sig["state"] == "unsigned":
            print("  Origin NOT established: this certificate carries no signature.")
        if not res["source_checked"]:
            print("  Note: run again with --source <file> to confirm it binds to real code.")
    else:
        print(f"  NOT VERIFIED -- {len(res['failures'])} check(s) failed.")
        if sig["state"] == "invalid":
            print("  The signature does not verify. Treat this certificate as forged.")
        else:
            print("  This certificate does not match its declared inputs.")
    print()


def main() -> int:
    p = argparse.ArgumentParser(
        description="Verify an AICVS certificate offline, without AICVS.",
        epilog="Exit codes: 0 verified, 1 failed, 2 bad input.")
    p.add_argument("bundle", help="path to the certificate bundle .json")
    p.add_argument("--source", help="original source file, to prove the certificate binds to it")
    p.add_argument("--public-key", default="",
                   help="base64 Ed25519 public key to pin, from the published key list")
    p.add_argument("--require-signature", action="store_true",
                   help="fail unless the signature verifies against a pinned key")
    p.add_argument("--json", action="store_true", help="emit the result as JSON")
    p.add_argument("--quiet", action="store_true", help="print nothing; use the exit code")
    a = p.parse_args()

    try:
        res = verify(load_bundle(a.bundle), a.source,
                     pinned_key_b64=a.public_key,
                     require_signature=a.require_signature)
    except BadBundle as e:
        if not a.quiet:
            print(f"error: {e}", file=sys.stderr)
        return 2

    if a.json:
        print(json.dumps(res, indent=2))
    elif not a.quiet:
        report(res)
    return 0 if res["verified"] else 1


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