#!/usr/bin/env python3
"""Verify a Human Seal approval offline. One file. Standard library plus the `cryptography` package. No network, no FLOCORE code.

What it checks, for the two credential forms Human Seal issues (SD-JWT VC and the W3C VC 2.0 JWS form):
  signature (Ed25519, alg EdDSA only; none and any other algorithm refused), type, expiry and not-before, that every Disclosure matches a digest the issuer signed,
  that the credential is an approval, and, if you give it the original action, that the action is the one that was approved (content hash).

What it does NOT do: it does not fetch the key (you pass the issuer's published key, a JWK, a JWKS or a DID document), it does not check the revocation list
(fetch that yourself), and it refuses a token that carries a Key Binding JWT because key binding is not implemented.

Use:   python humanseal_verify.py TOKEN_OR_FILE --key jwks.json [--action action.json] [--issuer did:web:...] [--vct URL]
Or:    from humanseal_verify import verify
"""
from __future__ import annotations

import argparse
import base64
import hashlib
import hmac
import json
import re
import sys
import time
from datetime import datetime, timezone

DEFAULT_VCT = "https://fo.flocore.tech/vct/human-seal-approval/v1"
SD_JWT_TYPS = ("dc+sd-jwt", "vc+sd-jwt")
VC_JWT_TYP = "vc+jwt"
_B64U = re.compile(r"^[A-Za-z0-9_-]*$")


def _unb64u(value):
    if not isinstance(value, str) or not _B64U.match(value):
        raise ValueError("not base64url (padding and other characters are not allowed)")
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def _no_dupes(pairs):
    d = {}
    for k, v in pairs:
        if k in d:
            raise ValueError(f"duplicate JSON key {k!r}")
        d[k] = v
    return d


def _loads_b64u(part):
    return json.loads(_unb64u(part).decode("utf-8"), object_pairs_hook=_no_dupes)


def _epoch(now):
    if now is None:
        return int(time.time())
    if isinstance(now, datetime):
        return int(now.timestamp())
    return int(now)


def _parse_iso(value):
    try:
        dt = datetime.fromisoformat(str(value))
    except ValueError:
        return None
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return int(dt.timestamp())


def canonical(obj) -> str:
    """The action as it was hashed: sorted keys, no extra whitespace."""
    return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)


def content_hash(action) -> str:
    material = action if isinstance(action, str) else canonical(action)
    return "sha256:" + hashlib.sha256(material.encode("utf-8")).hexdigest()


def _digest(disclosure):
    return base64.urlsafe_b64encode(hashlib.sha256(disclosure.encode("ascii")).digest()).decode("ascii").rstrip("=")


def _okp_x(jwk):
    if isinstance(jwk, dict) and jwk.get("kty") == "OKP" and jwk.get("crv") == "Ed25519" and isinstance(jwk.get("x"), str):
        if str(jwk.get("key_status") or "") == "revoked":
            return None
        return jwk["x"]
    return None


def resolve_public_key(key, kid=None):
    """A base64url raw key, a JWK, a JWKS or a DID document in, the base64url raw key (or None) out."""
    if isinstance(key, str):
        return key or None
    if not isinstance(key, dict):
        return None
    if key.get("kty"):
        return _okp_x(key)
    cands = []
    if isinstance(key.get("keys"), list):
        cands = list(key["keys"])
    elif isinstance(key.get("verificationMethod"), list):
        cands = [dict(vm.get("publicKeyJwk") or {}, kid=vm.get("id")) for vm in key["verificationMethod"] if isinstance(vm, dict)]
    if kid:
        cands = [k for k in cands if isinstance(k, dict) and k.get("kid") == kid]
    elif len(cands) != 1:
        return None
    for k in cands:
        x = _okp_x(k)
        if x:
            return x
    return None


def _verify_jws(jws, key, allowed_typ):
    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
    parts = jws.split(".")
    if len(parts) != 3:
        raise ValueError("not a compact JWS")
    try:
        header, payload = _loads_b64u(parts[0]), _loads_b64u(parts[1])
    except Exception as exc:
        raise ValueError(f"malformed JWS: {type(exc).__name__}") from exc
    if not isinstance(header, dict) or not isinstance(payload, dict):
        raise ValueError("JWS header and payload must be objects")
    if header.get("alg") != "EdDSA":
        raise ValueError("alg must be EdDSA")
    if "crit" in header:
        raise ValueError("crit header is not supported")
    if header.get("typ") not in allowed_typ:
        raise ValueError(f"typ must be one of {list(allowed_typ)}")
    x = resolve_public_key(key, header.get("kid") if isinstance(key, dict) else None)
    if not x:
        raise ValueError("no usable Ed25519 public key for this token")
    try:
        Ed25519PublicKey.from_public_bytes(_unb64u(x)).verify(_unb64u(parts[2]), (parts[0] + "." + parts[1]).encode("ascii"))
    except Exception as exc:
        raise ValueError("signature check failed") from exc
    return header, payload


def _check_times(claims, now_e, leeway, require_exp):
    exp, nbf, iat = claims.get("exp"), claims.get("nbf"), claims.get("iat")
    for name, v in (("exp", exp), ("nbf", nbf), ("iat", iat)):
        if v is not None and (isinstance(v, bool) or not isinstance(v, (int, float))):
            raise ValueError(f"{name} must be a number")
    if exp is None and require_exp:
        raise ValueError("credential has no exp")
    if exp is not None and now_e > exp + leeway:
        raise ValueError("credential expired")
    if nbf is not None and now_e + leeway < nbf:
        raise ValueError("credential not yet valid (nbf)")
    if iat is not None and now_e + leeway < iat:
        raise ValueError("credential issued in the future (iat)")


def _bind(claims, action, now_e, enforce_approval_expiry):
    out = {"content_bound": False}
    if claims.get("decision") != "approved":
        raise ValueError("credential is not an approval")
    if action is not None:
        if not isinstance(claims.get("content_hash"), str) or not hmac.compare_digest(content_hash(action), claims["content_hash"]):
            raise ValueError("content_hash does not match the supplied action (possible swap)")
        out["content_bound"] = True
    avu = claims.get("approval_valid_until")
    if enforce_approval_expiry and avu:
        e = _parse_iso(avu)
        if e is None:
            raise ValueError("approval_valid_until is unreadable")
        if now_e > e:
            raise ValueError("the approval itself has expired (approval_valid_until)")
    return out


def _has_nested_sd(v):
    if isinstance(v, dict):
        return "_sd" in v or "..." in v or any(_has_nested_sd(x) for x in v.values())
    if isinstance(v, list):
        return any(_has_nested_sd(x) for x in v)
    return False


def _verify_sd_jwt(token, key, action, now, leeway, issuer, vct, require_exp, enforce_approval_expiry):
    if not isinstance(token, str) or "~" not in token:
        raise ValueError("not an SD-JWT (no '~' separators)")
    parts = token.split("~")
    jwt, mid, kb = parts[0], parts[1:-1], parts[-1]
    if kb != "":
        raise ValueError("a Key Binding JWT is present; key binding verification is not implemented, so the token is refused")
    header, payload = _verify_jws(jwt, key, SD_JWT_TYPS)
    if payload.get("_sd_alg", "sha-256") != "sha-256":
        raise ValueError("_sd_alg must be sha-256")
    if any(isinstance(v, (dict, list)) and _has_nested_sd(v) for v in payload.values()):
        raise ValueError("nested or array selective disclosure is not supported")
    listed = payload.get("_sd", [])
    if not isinstance(listed, list) or not all(isinstance(x, str) for x in listed):
        raise ValueError("_sd must be an array of strings")
    if len(set(listed)) != len(listed):
        raise ValueError("duplicate digest in _sd")
    listed_set = set(listed)
    claims = {k: v for k, v in payload.items() if k not in ("_sd", "_sd_alg")}
    disclosed, seen = [], set()
    for d in mid:
        if not d:
            raise ValueError("empty Disclosure")
        try:
            arr = _loads_b64u(d)
            dg = _digest(d)
        except Exception as exc:
            raise ValueError(f"malformed Disclosure: {type(exc).__name__}") from exc
        if dg in seen:
            raise ValueError("duplicate Disclosure")
        seen.add(dg)
        if dg not in listed_set:
            raise ValueError("a Disclosure is not listed in _sd (unlisted or tampered)")
        if not (isinstance(arr, list) and len(arr) == 3 and isinstance(arr[0], str) and isinstance(arr[1], str)):
            raise ValueError("Disclosure must be [salt, name, value]")
        if arr[1] in ("_sd", "...") or arr[1] in claims:
            raise ValueError(f"Disclosure claim name {arr[1]!r} is not allowed or collides with a plain claim")
        claims[arr[1]] = arr[2]
        disclosed.append(arr[1])
    now_e = _epoch(now)
    _check_times(claims, now_e, leeway, require_exp)
    if issuer and claims.get("iss") != issuer:
        raise ValueError("unexpected issuer")
    if vct and claims.get("vct") != vct:
        raise ValueError("unexpected vct")
    bound = _bind(claims, action, now_e, enforce_approval_expiry)
    return {"valid": True, "reason": "signature valid; Disclosures match _sd" + ("; action bound by content_hash" if bound["content_bound"]
            else "; action NOT checked (no action supplied)"), "claims": claims, "disclosed": disclosed,
            "withheld_count": len(listed) - len(disclosed), "kid": header.get("kid"), **bound}


def _verify_vc_jwt(token, key, action, now, leeway, issuer, enforce_approval_expiry):
    if not isinstance(token, str) or "~" in token:
        raise ValueError("not a compact JWS")
    header, vc = _verify_jws(token, key, (VC_JWT_TYP,))
    if header.get("cty") != "vc":
        raise ValueError("cty must be vc")
    ctx = vc.get("@context")
    if not (isinstance(ctx, list) and ctx and ctx[0] == "https://www.w3.org/ns/credentials/v2"):
        raise ValueError("first @context entry must be the VC 2.0 context")
    if "VerifiableCredential" not in (vc.get("type") or []):
        raise ValueError("type must include VerifiableCredential")
    if issuer and vc.get("issuer") != issuer:
        raise ValueError("unexpected issuer")
    now_e = _epoch(now)
    vf, vu = _parse_iso(vc.get("validFrom")), _parse_iso(vc.get("validUntil"))
    if vf is None or vu is None:
        raise ValueError("validFrom and validUntil are required and must be ISO 8601")
    if now_e > vu + leeway:
        raise ValueError("credential expired")
    if now_e + leeway < vf:
        raise ValueError("credential not yet valid")
    subject = vc.get("credentialSubject")
    if not isinstance(subject, dict):
        raise ValueError("credentialSubject must be an object")
    bound = _bind(subject, action, now_e, enforce_approval_expiry)
    return {"valid": True, "reason": "signature valid" + ("; action bound by content_hash" if bound["content_bound"]
            else "; action NOT checked (no action supplied)"), "claims": subject, "kid": header.get("kid"), **bound}


def verify(token, key, *, action=None, now=None, leeway=60, issuer=None, vct=DEFAULT_VCT, require_exp=True, enforce_approval_expiry=True):
    """Verify either credential form (the token itself says which: an SD-JWT has '~'). Never raises on bad input; returns {valid, reason, ...}."""
    try:
        if isinstance(token, str) and "~" in token:
            return _verify_sd_jwt(token.strip(), key, action, now, leeway, issuer, vct, require_exp, enforce_approval_expiry)
        return _verify_vc_jwt(token.strip() if isinstance(token, str) else token, key, action, now, leeway, issuer, enforce_approval_expiry)
    except ValueError as exc:
        return {"valid": False, "reason": str(exc)}
    except Exception as exc:  # a hostile token must never raise out of a verifier
        return {"valid": False, "reason": f"unreadable token: {type(exc).__name__}"}


def _load(arg):
    try:
        with open(arg, encoding="utf-8") as f:
            return f.read().strip()
    except OSError:
        return arg


def main(argv):
    ap = argparse.ArgumentParser(description="Verify a Human Seal approval offline.")
    ap.add_argument("token", help="the credential, or a file holding it")
    ap.add_argument("--key", required=True, help="the issuer's published key: a file holding a JWK, JWKS or DID document, or a base64url raw Ed25519 key")
    ap.add_argument("--action", help="a JSON file holding the original action, to check it is the one approved")
    ap.add_argument("--issuer", help="require this issuer (did:web:...)")
    ap.add_argument("--vct", default=DEFAULT_VCT, help="expected credential type; pass an empty string to skip")
    a = ap.parse_args(argv)
    k = _load(a.key)
    try:
        k = json.loads(k)
    except ValueError:
        pass
    action = json.loads(_load(a.action)) if a.action else None
    r = verify(_load(a.token), k, action=action, issuer=a.issuer, vct=a.vct or None)
    print(json.dumps({x: r[x] for x in ("valid", "reason", "content_bound") if x in r}, indent=2))
    return 0 if r["valid"] else 1


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