#!/usr/bin/env python

"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""

import hashlib
import hmac
import json
import re

from lib.core.convert import decodeBase64
from lib.core.convert import encodeBase64
from lib.core.convert import getBytes
from lib.core.convert import getText

# a compact JSON Web Token: base64url(header).base64url(payload).base64url(signature); a header always starts
# with '{"' which base64url-encodes to the literal prefix 'eyJ', so this matches JWTs embedded in a larger value
JWT_REGEX = r"eyJ[A-Za-z0-9_-]{4,}\.eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*"

# keyed-hash algorithms sqlmap can both verify (crack) and forge offline
HMAC_ALGORITHMS = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512}

def encodeSegment(value):
    return encodeBase64(json.dumps(value, separators=(',', ':')), binary=False, safe=True)

def parseJWT(token):
    """Split and decode a JWT into its header/payload/signature parts (None if it is not a well-formed JWT).

    >>> data = parseJWT("eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ.")
    >>> data["header"]["alg"] == "none" and data["payload"]["user"] == "admin"
    True
    >>> parseJWT("not.a.jwt") is None
    True
    """

    if not token or token.count('.') != 2:
        return None

    header, payload, signature = token.split('.')

    try:
        header = json.loads(decodeBase64(header, binary=False))
        payload = json.loads(decodeBase64(payload, binary=False))
    except Exception:
        return None

    if not isinstance(header, dict) or "alg" not in header:
        return None

    return {"header": header, "payload": payload, "signature": signature, "signingInput": token.rsplit('.', 1)[0], "raw": token}

def findJWTs(value):
    """Return every well-formed JWT found inside an arbitrary value (e.g. a Cookie/Authorization header)."""

    return [match.group(0) for match in re.finditer(JWT_REGEX, value or "") if parseJWT(match.group(0))]

def forgeJWT(header, payload, key=None):
    """Re-encode a (possibly tampered) header/payload, signing with 'key' for an HMAC 'alg' or leaving the
    signature empty for 'alg':'none' - the primitive behind the alg:none and weak-secret exploitation paths.

    >>> forgeJWT({"alg": "none"}, {"user": "admin"}).endswith('.')
    True
    >>> parseJWT(forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="secret"))["payload"]["user"] == "admin"
    True
    """

    alg = (header.get("alg") or "none")
    signingInput = "%s.%s" % (encodeSegment(header), encodeSegment(payload))

    if alg.lower() == "none":
        signature = ""
    elif alg.upper() in HMAC_ALGORITHMS and key is not None:
        digest = hmac.new(getBytes(key), getBytes(signingInput), HMAC_ALGORITHMS[alg.upper()]).digest()
        signature = encodeBase64(digest, binary=False, safe=True)
    else:
        raise ValueError("unsupported algorithm '%s' for forging" % alg)

    return "%s.%s" % (signingInput, signature)

def crackHMAC(token, secrets, limit=None):
    """Try to recover the HMAC signing secret of an HS* token from an iterable of candidate secrets; returns
    the secret on success (a full forgery primitive), else None. Purely offline - no requests.

    >>> token = forgeJWT({"alg": "HS256"}, {"user": "admin"}, key="s3cr3t")
    >>> crackHMAC(token, ["admin", "s3cr3t", "letmein"])
    's3cr3t'
    >>> crackHMAC(token, ["admin", "letmein"]) is None
    True
    """

    data = parseJWT(token)
    if not data or (data["header"].get("alg") or "").upper() not in HMAC_ALGORITHMS:
        return None

    fn = HMAC_ALGORITHMS[data["header"]["alg"].upper()]
    signingInput = getBytes(data["signingInput"])
    target = decodeBase64(data["signature"], binary=True)

    for index, secret in enumerate(secrets):
        if limit is not None and index >= limit:
            break
        secret = secret.strip() if hasattr(secret, "strip") else secret
        if hmac.new(getBytes(secret), signingInput, fn).digest() == target:
            return getText(secret)

    return None

def auditJWT(token, secrets=None, crackLimit=None):
    """Offline heuristic battery over a single JWT - the 'bad JWT setup' checks that bite in the real world and
    CTFs. Returns findings as (id, severity, summary, detail); online oracle checks (does the server ACCEPT an
    alg:none / bit-flipped / expired forgery) are layered on top by the caller, which owns response comparison.

    >>> sorted(_[0] for _ in auditJWT("eyJhbGciOiJub25lIn0.eyJ1c2VyIjoiYWRtaW4ifQ."))
    ['alg-none', 'no-expiry']
    """

    findings = []
    data = parseJWT(token)
    if not data:
        return findings

    header, payload = data["header"], data["payload"]
    alg = (header.get("alg") or "").strip()

    # an unsigned token that the app already issued means forged claims need no key at all
    if alg.lower() == "none" or data["signature"] == "":
        findings.append(("alg-none", "critical", "token declares alg '%s' (unsigned)" % (alg or "none"), "claims can be forged with no key"))

    # a guessable HMAC secret is a full forgery primitive - crack it against the provided dictionary
    if alg.upper() in HMAC_ALGORITHMS and secrets is not None:
        secret = crackHMAC(token, secrets, crackLimit)
        if secret is not None:
            findings.append(("weak-hmac-secret", "critical", "HMAC secret recovered ('%s')" % secret, "arbitrary tokens can be forged and re-signed"))

    # an asymmetric token may be vulnerable to RS/HS confusion if the public key is retrievable
    if alg.upper().startswith(("RS", "ES", "PS")):
        findings.append(("alg-confusion", "info", "asymmetric algorithm '%s'" % alg, "test RS/HS confusion if the public key is obtainable (JWKS/TLS)"))

    # header fields that pull in attacker-controllable key material (CVE-2018-0114 class)
    for field in ("jku", "x5u", "jwk", "x5c"):
        if field in header:
            findings.append(("header-key-injection", "high", "header carries '%s'" % field, "attacker-hosted key material may be trusted"))

    # 'kid' commonly feeds a key lookup (file/DB/command) - a natural injection point
    if "kid" in header:
        findings.append(("kid-injection", "info", "header carries 'kid'", "candidate injection point (SQLi/LFI/path/command via key lookup)"))

    if isinstance(payload, dict) and "exp" not in payload:
        findings.append(("no-expiry", "high", "no 'exp' claim", "token does not expire"))

    return findings
