#!/usr/bin/env python3
# Airlock IAM legacy ESP signed-ticket inspection helper.
#
# Usage:
#   python3 esp_ticket_tool.py ticket.txt
#   python3 esp_ticket_tool.py --recover ticket1.txt ticket2.txt
#
# A ticket file may contain raw base64 or a line/header containing the ticket.
# The helper verifies the fixed Airlock ESP RSA-512/SHA1 signature, decodes the
# identity and roles payload, and can recover the fixed public modulus from two
# known signed tickets. No private material or target credentials are required.

import argparse
import base64
import datetime as dt
import hashlib
import math
import re
from pathlib import Path

FIXED_N = int(
    "ca9826ba06d8381081e408714dea920103b1399d8e1fa2abc28c2b9227e4e425"
    "159027a07b82e5202db2045d8d75fb37df5d4ac52e8fe8370a659ff14e615f1b",
    16,
)
FIXED_E = 65537
SHA1_DIGEST_INFO = bytes.fromhex("3021300906052b0e03021a05000414")


def read_ticket(value: str) -> bytes:
    p = Path(value)
    text = p.read_text(errors="ignore") if p.exists() else value
    candidates = re.findall(r"[A-Za-z0-9+/]{80,}={0,2}", text)
    if not candidates:
        raise ValueError(f"no base64 ticket found in {value}")
    return base64.b64decode(max(candidates, key=len))


def emsa_sha1(message: bytes) -> bytes:
    digest_info = SHA1_DIGEST_INFO + hashlib.sha1(message).digest()
    pad = b"\xff" * (64 - len(digest_info) - 3)
    return b"\x00\x01" + pad + b"\x00" + digest_info


def parse(raw: bytes) -> dict:
    if len(raw) < 74:
        raise ValueError(f"ticket too short: {len(raw)} bytes")
    version = raw[0]
    signature = raw[1:65]
    message = raw[65:]
    clear = message[:-8]
    expiry_ms = int.from_bytes(message[-8:], "big")
    verified = pow(int.from_bytes(signature, "big"), FIXED_E, FIXED_N).to_bytes(64, "big") == emsa_sha1(message)
    return {
        "version": version,
        "signature_hex": signature.hex(),
        "cleartext": clear.decode("utf-8", "replace"),
        "expiry_ms": expiry_ms,
        "expiry_utc": dt.datetime.fromtimestamp(expiry_ms / 1000, dt.timezone.utc).isoformat(),
        "signature_valid": verified,
    }


def recover_modulus(a: bytes, b: bytes) -> int:
    sa, sb = int.from_bytes(a[1:65], "big"), int.from_bytes(b[1:65], "big")
    ma, mb = int.from_bytes(emsa_sha1(a[65:]), "big"), int.from_bytes(emsa_sha1(b[65:]), "big")
    try:
        import gmpy2
        g = int(gmpy2.gcd(gmpy2.mpz(sa) ** FIXED_E - ma, gmpy2.mpz(sb) ** FIXED_E - mb))
    except ImportError:
        g = math.gcd(pow(sa, FIXED_E) - ma, pow(sb, FIXED_E) - mb)
    for small in range(2, 10000):
        while g % small == 0 and (g // small).bit_length() >= 500:
            g //= small
    return g


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("tickets", nargs="+")
    ap.add_argument("--recover", action="store_true")
    args = ap.parse_args()
    raws = [read_ticket(x) for x in args.tickets]
    if args.recover:
        if len(raws) != 2:
            ap.error("--recover requires exactly two tickets")
        n = recover_modulus(*raws)
        print(f"n={n:x}")
        print(f"bits={n.bit_length()}")
        print(f"matches_fixed={n == FIXED_N}")
        return
    for name, raw in zip(args.tickets, raws):
        info = parse(raw)
        print(f"[{name}]")
        for key, value in info.items():
            print(f"{key}={value}")


if __name__ == "__main__":
    main()
