#!/usr/bin/env python3
"""
verify_eve_decision.py — Independently verify an EVE CoreGuard governance
decision evidence record. STANDALONE: this script imports NOTHING from EVE.

A bank's model-risk / audit team can run this with only:
    pip install cryptography
and EVE's published public key (https://evecore.com/.well-known/eve-pubkey).

It recomputes the decision hash and verifies the asymmetric signature against
the PUBLIC key. Production decision certificates are signed with **ECDSA P-384
(SHA-384), AWS KMS-resident** — the private key never leaves the KMS/HSM
boundary. Legacy records signed with **Ed25519** are also verified (the
published key selects the algorithm). No shared secret, no EVE service call, no
ability to forge. If any field of the signed record is altered, verification
FAILS.

Usage:
    # verify a record against the live published key
    python verify_eve_decision.py record.json

    # verify against a local public-key PEM (fully offline / air-gapped)
    python verify_eve_decision.py record.json --pubkey eve-pubkey.pem

    # read the record from stdin
    cat record.json | python verify_eve_decision.py -

The record may be a full evidence object (with a "decision_record" field, as
returned by /v1/decisions/evaluate?include_evidence=true), a bare
decision_record, an agent Decision Certificate (schema "eve.agent.action.v1",
as returned by /api/agent-proof/sample), or a bundle with a "certificates"
array (the first entry is verified).

Exit code: 0 = VERIFIED, 1 = FAILED/INVALID, 2 = usage/dependency error.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import sys
import urllib.request

DEFAULT_PUBKEY_URL = "https://evecore.com/.well-known/eve-pubkey"

# These field names + the canonicalization below MUST match how EVE signs a
# decision (core/coreguard/audit.py: build_audit_record / verify_audit). They
# are reproduced here verbatim so this verifier stays independent of EVE code.
_HASH_FIELDS = ("audit_id", "request_id", "decision", "violations",
                "risk_score", "timestamp", "org_id",
                "audit_schema", "charter_hash", "policy_version")

# v3 content-binding signed fields — mirrors core/coreguard/audit._V3_FIELD_DEFAULTS
# EXACTLY (key set + defaults). Applies to audit_schema="3" records that declare
# payload_schema="eve.decision.v3". Order is irrelevant (keys sorted before hashing).
_V3_FIELD_DEFAULTS = (
    ("charter_hash", ""),
    ("policy_version", ""),
    ("payload_schema", "eve.decision.v3"),
    ("request_payload_sha256", ""),
    ("request_canonicalization", ""),
    ("response_payload_sha256", ""),
    ("response_canonicalization", ""),
    ("response_released", False),
    ("rule_results_sha256", ""),
    ("signature_key_id", ""),
    ("governance_verdict", ""),
    ("response_disposition", ""),
    ("business_decision", ""),
)

# v4 content-binding signed fields — mirrors the deployed engine's
# core/coreguard/audit._V4_CONTENT_DEFAULTS EXACTLY (aws-deploy lineage).
# Applies to audit_schema="4" records; charter/policy ride alongside and the
# eve.assurance.v1 attestation block is included only when present.
_V4_CONTENT_DEFAULTS = (
    ("payload_schema", "eve.decision.v4"),
    ("request_payload_sha256", ""),
    ("request_canonicalization", ""),
    ("response_payload_sha256", ""),
    ("response_canonicalization", ""),
    ("response_released", False),
    ("rule_results_sha256", ""),
    ("governance_verdict", ""),
    ("response_disposition", ""),
    ("business_decision", ""),
)

# eve.agent.action.v1 signing envelope — the agent Decision Certificate
# (core/agent_gateway / core/agent_authority cert_signing) hashes every field
# EXCEPT this envelope with COMPACT canonicalization
# (json.dumps(sort_keys=True, separators=(",", ":"))), stores it as
# ``content_hash``, and the signature covers that content_hash STRING.
_AGENT_ENVELOPE = (
    "content_hash", "signature", "algorithm", "kid",
    "key_fingerprint", "verification_key_url",
)


def _canonical_hash(record: dict) -> str:
    """Recompute the 'sha256-<hex>' content hash over the signed payload.

    Values are used AS EMITTED — EVE hashes the field types it received (e.g. a
    whole-number risk_score is signed as int ``0``, not ``0.0``), and those
    types survive a JSON round-trip, so no coercion is applied here.
    """
    payload = {
        "audit_id":   record.get("audit_id"),
        "request_id": record.get("request_id", ""),
        "decision":   record.get("decision", ""),
        "violations": record.get("violations", 0),
        "risk_score": record.get("risk_score", 0),
        "timestamp":  record.get("timestamp", ""),
    }
    # org_id is part of the signed payload only when present (tenant records).
    if "org_id" in record:
        payload["org_id"] = record["org_id"]
    # Schema-aware binding, presence-gated like org_id so v1 records verify
    # unchanged. Exactly one branch runs per record; each mirrors its
    # authoritative backend byte-for-byte:
    #   "2"                                  — policy binding (charter+policy signed)
    #   "3" + payload_schema eve.decision.v3 — content binding (current v3 contract)
    #   "3" without it                        — legacy assurance binding
    #                                           (v2 fields + attestation)
    #   "4"                                  — deployed-engine content binding
    #                                           (eve.decision.v4)
    _schema = record.get("audit_schema")
    if _schema == "3" and record.get("payload_schema") == "eve.decision.v3":
        payload["audit_schema"] = "3"
        for _k, _dflt in _V3_FIELD_DEFAULTS:
            payload[_k] = record.get(_k, _dflt)
    elif _schema in ("2", "3", "4"):
        payload["audit_schema"] = _schema
        payload["charter_hash"] = record.get("charter_hash", "")
        payload["policy_version"] = record.get("policy_version", "")
        if _schema == "3":
            payload["attestation"] = record.get("attestation")
        if _schema == "4":
            if record.get("attestation") is not None:
                payload["attestation"] = record["attestation"]
            for _k, _dflt in _V4_CONTENT_DEFAULTS:
                payload[_k] = record.get(_k, _dflt)
    canonical = json.dumps(payload, sort_keys=True, default=str)
    return "sha256-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _full_record_hash(record: dict) -> str:
    """Reconstruct the hash over EVERY field except 'hash'/'signature'.

    Some decision records (e.g. chat/demo records) bind MORE fields than the
    fixed eve.decision.v3/v4 signed-field set — the backend hashes the WHOLE
    payload (every field except hash/signature). This mirrors that exactly so
    such over-bound records verify. A tampered record fails BOTH this and the
    fixed-field reconstruction, so this fallback never weakens the check — it
    only recognizes records whose signature legitimately covers MORE fields.
    """
    payload = {k: v for k, v in record.items() if k not in ("hash", "signature")}
    canonical = json.dumps(payload, sort_keys=True, default=str)
    return "sha256-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _canonical_agent_hash(record: dict) -> str:
    """Recompute the 'sha256-<hex>' content hash of an eve.agent.action.v1
    Decision Certificate. Compact canonicalization over every field except the
    signing envelope, byte-for-byte matching the backend cert signer.
    """
    payload = {k: v for k, v in record.items() if k not in _AGENT_ENVELOPE}
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)
    return "sha256-" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _is_agent_cert(record: dict) -> bool:
    return "content_hash" in record and str(record.get("schema", "")).startswith("eve.agent")


def _load_pubkey(args):
    if args.pubkey:
        with open(args.pubkey, "rb") as fh:
            pem = fh.read()
    else:
        # An explicit User-Agent is required: the CDN in front of eveaicore.com
        # rejects the default "Python-urllib/*" agent with HTTP 403. (For a
        # fully offline / air-gapped check, pass --pubkey with the bundled PEM.)
        req = urllib.request.Request(
            args.pubkey_url,
            headers={"User-Agent": "eve-verify/1.0 (+https://evecore.com)",
                     "Accept": "application/x-pem-file, */*"},
        )
        with urllib.request.urlopen(req, timeout=20) as resp:
            pem = resp.read()
    from cryptography.hazmat.primitives.serialization import load_pem_public_key
    return load_pem_public_key(pem)


def _verify_signature(pub, stored_sig: str, signed_message: bytes) -> tuple[bool, str]:
    """Verify an asymmetric signature over ``signed_message`` (the content-hash
    STRING bytes). Algorithm is selected by the signature prefix. Returns
    (ok, algo_label_or_error).
    """
    from cryptography.exceptions import InvalidSignature
    if stored_sig.startswith("hmac-"):
        return False, ("signature is HMAC (symmetric) — NOT independently "
                       "verifiable. Ask EVE to sign with an asymmetric key "
                       "(ECDSA P-384 / Ed25519).")
    try:
        if stored_sig.startswith("kms-ecdsa-p384-") or stored_sig.startswith("ecdsa-p384-"):
            from cryptography.hazmat.primitives.asymmetric import ec
            from cryptography.hazmat.primitives import hashes
            sig = bytes.fromhex(stored_sig.split("p384-", 1)[1])
            pub.verify(sig, signed_message, ec.ECDSA(hashes.SHA384()))
            return True, "ECDSA P-384 (SHA-384, AWS KMS)"
        if stored_sig.startswith("ed25519-"):
            sig = bytes.fromhex(stored_sig[len("ed25519-"):])
            pub.verify(sig, signed_message)  # Ed25519 takes no hash algorithm
            return True, "Ed25519"
        return False, f"unsupported signature algorithm: {stored_sig.split('-')[0]}"
    except InvalidSignature:
        return False, ("SIGNATURE INVALID — the record was not produced by the "
                       "key behind the published public key (or was tampered with).")
    except Exception as exc:  # noqa: BLE001
        # Most commonly an algorithm/key-type mismatch (e.g. an Ed25519 record
        # checked against the ECDSA P-384 published key). That is a genuine
        # verification failure, surfaced clearly.
        return False, (f"verification error: {type(exc).__name__}: {exc} "
                       "(signature algorithm may not match the published key)")


def verify(record: dict, args) -> tuple[bool, str]:
    # --- Agent Decision Certificate (eve.agent.action.v1) --------------------
    if _is_agent_cert(record):
        stored_hash = record.get("content_hash", "")
        stored_sig = record.get("signature", "")
        if not stored_hash or not stored_sig:
            return False, "record is missing 'content_hash' or 'signature'"
        expected_hash = _canonical_agent_hash(record)
        if expected_hash != stored_hash:
            return False, (
                "HASH MISMATCH — a field in the certificate was altered.\n"
                f"  recomputed: {expected_hash}\n  stored:     {stored_hash}"
            )
        pub = _load_pubkey(args)
        ok, info = _verify_signature(pub, stored_sig, expected_hash.encode("utf-8"))
        if not ok:
            return False, info
        return True, (f"VERIFIED — hash recomputed and {info} signature confirmed "
                      "against the published public key. No EVE involvement required.")

    # --- Decision evidence record (eve.decision.v3 / v4 / legacy) ------------
    stored_hash = record.get("hash", "")
    stored_sig = record.get("signature", "")
    if not stored_hash or not stored_sig:
        return False, "record is missing 'hash' or 'signature'"

    # 1. Tamper check: recompute the hash over the decision fields. Try the fixed
    #    eve.decision.v3/v4 signed-field set first (legacy + /v1/decisions/evaluate
    #    records); if that does not match, fall back to the full-record
    #    reconstruction (every field except hash/signature) used by over-bound
    #    records (e.g. chat/demo records binding extra fixture/operator provenance).
    #    A genuine tamper fails BOTH reconstructions.
    expected_hash = _canonical_hash(record)
    if expected_hash != stored_hash:
        full_hash = _full_record_hash(record)
        if full_hash == stored_hash:
            expected_hash = full_hash
        else:
            return False, (
                "HASH MISMATCH — a field in the decision record was altered.\n"
                f"  recomputed (fixed-field): {expected_hash}\n"
                f"  recomputed (full-record): {full_hash}\n"
                f"  stored:                   {stored_hash}"
            )

    # 2. Signature check. Production certificates are ECDSA P-384 (AWS KMS);
    #    legacy records may be Ed25519. Both are third-party verifiable with the
    #    published public key. HMAC records require the shared secret (not
    #    third-party verifiable).
    pub = _load_pubkey(args)
    ok, info = _verify_signature(pub, stored_sig, expected_hash.encode("utf-8"))
    if not ok:
        return False, info
    return True, (f"VERIFIED — hash recomputed and {info} signature confirmed "
                  "against the published public key. No EVE involvement required.")


def _extract_record(obj: dict) -> dict:
    """Accept a full evidence object, a bare decision_record, or a bundle with a
    'certificates' array (verify the first certificate)."""
    if isinstance(obj, dict):
        if isinstance(obj.get("decision_record"), dict):
            return obj["decision_record"]
        certs = obj.get("certificates")
        if isinstance(certs, list) and certs and isinstance(certs[0], dict):
            return certs[0]
    return obj


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description="Independently verify an EVE CoreGuard decision evidence record.")
    ap.add_argument("record", help="path to the evidence/decision JSON ('-' for stdin)")
    ap.add_argument("--pubkey", help="path to a public-key PEM (offline verification)")
    ap.add_argument("--pubkey-url", default=DEFAULT_PUBKEY_URL,
                    help=f"URL of the published public key (default: {DEFAULT_PUBKEY_URL})")
    args = ap.parse_args(argv)

    try:
        import cryptography  # noqa: F401
    except ImportError:
        print("ERROR: this verifier needs the 'cryptography' package: pip install cryptography",
              file=sys.stderr)
        return 2

    try:
        raw = sys.stdin.read() if args.record == "-" else open(args.record, "r", encoding="utf-8").read()
        obj = json.loads(raw)
    except Exception as exc:  # noqa: BLE001
        print(f"ERROR: could not read/parse record: {exc}", file=sys.stderr)
        return 2

    record = _extract_record(obj)
    ok, message = verify(record, args)
    decision = record.get("decision", record.get("disposition", "?"))
    ident = record.get("audit_id", record.get("certificate_id", "?"))
    print(f"Decision : {decision}")
    print(f"Record   : {ident}")
    print(f"Result   : {'[VERIFIED] ' if ok else '[FAILED] '}{message}")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
