#!/usr/pkg/bin/python3.13

#
# Author......: See docs/credits.txt
# License.....: MIT
#

from argparse import ArgumentParser
from base64 import b64decode
from collections import namedtuple
from dataclasses import dataclass
from os import SEEK_SET
from struct import Struct
from typing import List
import json

try:
    from enum import auto, IntEnum, StrEnum
except ImportError:
    from enum import auto, Enum, IntEnum

    class StrEnum(str, Enum):
        def _generate_next_value_(name, start, count, last_values):
            return name.lower()

        __str__ = str.__str__

        __format__ = str.__format__


# consts


SIGNATURE = "$luks$"
SECTOR_SIZE = 512


# utils


def bytes_to_str(value):
    """
    Convert encoded padded bytes string into str.
    """
    return value.rstrip(b"\0").decode()


# pre-header


TmpHeaderPre = namedtuple(
    "TmpHeaderPre",
    (
        "magic",
        "version",
    ),
)


# version 1


TmpKeyVersion1 = namedtuple(
    "TmpKeyVersion1",
    (
        "active",
        "iterations",
        "salt",
        "material_offset",
        "stripes",
    ),
)


@dataclass(init=False)
class KeyVersion1:
    class Active(IntEnum):
        ENABLED = 0x00AC71F3
        DISABLED = 0x0000DEAD
        ENABLED_OLD = 0xCAFE
        DISABLED_OLD = 0x0000

    active: Active
    iterations: int
    salt: bytes
    af: bytes

    def __init__(self, active, iterations, salt, af):
        self.active = self.Active(active)
        if (self.active in [self.Active.ENABLED, self.Active.ENABLED_OLD]) and (iterations <= 0):
            raise ValueError("key iterations cannot be less than zero")
        self.iterations = iterations
        self.salt = salt
        self.af = af


TmpHeaderVersion1 = namedtuple(
    "TmpHeaderVersion1",
    (
        "magic",
        "version",
        "cipher",
        "mode",
        "hash",
        "payload_offset",
        "key_bytes",
        "digest",
        "salt",
        "iterations",
        "uuid",
        "keys",
    ),
)


@dataclass(init=False)
class HeaderVersion1:
    MAGIC = b"LUKS\xba\xbe"
    VERSION = 0x0001

    class Cipher(StrEnum):
        AES = auto()
        TWOFISH = auto()
        SERPENT = auto()

    class Mode(StrEnum):
        CBC_ESSIV_SHA256 = "cbc-essiv:sha256"
        CBC_PLAIN = "cbc-plain"
        CBC_PLAIN64 = "cbc-plain64"
        XTS_PLAIN = "xts-plain"
        XTS_PLAIN64 = "xts-plain64"

    class Hash(StrEnum):
        RIPEMD160 = auto()
        SHA1 = auto()
        SHA256 = auto()
        SHA512 = auto()
        WHIRLPOOL = auto()

    class KeySize(IntEnum):
        SIZE_128 = 128
        SIZE_256 = 256
        SIZE_512 = 512

    magic: bytes
    version: int
    cipher: Cipher
    mode: Mode
    hash: Hash
    payload: bytes
    key_size: KeySize
    digest: bytes
    salt: bytes
    iterations: int
    uuid: str
    keys: List[KeyVersion1]

    def __init__(self, magic, version, cipher, mode, hash, payload, key_size, digest, salt, iterations, uuid, keys):
        if magic != self.MAGIC:
            raise ValueError("invalid magic bytes")
        self.magic = magic
        if version != self.VERSION:
            raise ValueError("invalid version")
        self.version = version
        if isinstance(cipher, bytes):
            try:
                cipher = bytes_to_str(cipher)
                self.cipher = self.Cipher(cipher)
            except UnicodeDecodeError as e:
                raise ValueError("cannot decode cipher") from e
            except ValueError as e:
                raise ValueError("invalid cipher value") from e
        if isinstance(mode, bytes):
            try:
                mode = bytes_to_str(mode)
                self.mode = self.Mode(mode)
            except UnicodeDecodeError as e:
                raise ValueError("cannot decode mode") from e
            except ValueError as e:
                raise ValueError("invalid mode value") from e
        if isinstance(hash, bytes):
            try:
                hash = bytes_to_str(hash)
                self.hash = self.Hash(hash)
            except UnicodeDecodeError as e:
                raise ValueError("cannot decode hash") from e
            except ValueError as e:
                raise ValueError("invalid hash value") from e
        self.payload = payload
        try:
            self.key_size = self.KeySize(key_size)
        except ValueError as e:
            raise ValueError("invalid key size provided") from e
        self.digest = digest
        self.salt = salt
        try:
            iterations = int(iterations)
        except ValueError as e:
            raise ValueError("iterations is not a number") from e
        if iterations <= 0:
            raise ValueError("iterations cannot be less or equal to zero")
        self.iterations = iterations
        if isinstance(uuid, bytes):
            try:
                uuid = bytes_to_str(uuid)
            except UnicodeDecodeError as e:
                raise ValueError("cannot decode UUID") from e
        self.uuid = uuid
        if all(isinstance(key, tuple) for key in keys):
            keys = [KeyVersion1(*key) for key in keys]
        elif all(isinstance(key, dict) for key in keys):
            keys = [KeyVersion1(**key) for key in keys]
        if any(not isinstance(key, KeyVersion1) for key in keys):
            raise ValueError("not a key object provided")
        self.keys = keys


def extract_version1(file):
    # consts
    KEYS_COUNT = 8
    PADDING_LENGTH = 432
    PAYLOAD_SIZE = 512  # sizeof (u32) * 128

    # prepare structs
    key_struct = Struct(">LL32sLL")
    header_struct = Struct(
        ">6sH32s32s32sLL20s32sL40s" + str(key_struct.size * KEYS_COUNT) + "s" + str(PADDING_LENGTH) + "x"
    )

    # read header
    header = file.read(header_struct.size)
    if len(header) < header_struct.size:
        raise ValueError("file contains less data than needed (invalid header len)")

    # convert bytes into temporary header
    header = header_struct.unpack(header)
    header = TmpHeaderVersion1(*header)

    # convert bytes into temporary keys
    tmp_keys = [TmpKeyVersion1(*key) for key in key_struct.iter_unpack(header.keys)]

    # read keys' af
    keys = []
    for key in tmp_keys:
        file.seek(key.material_offset * SECTOR_SIZE, SEEK_SET)
        af = file.read(header.key_bytes * key.stripes)
        if len(af) < (header.key_bytes * key.stripes):
            raise ValueError("file contains less data than needed (invalid af len)")

        key = KeyVersion1(key.active, key.iterations, key.salt, af)
        keys.append(key)

    # read payload
    file.seek(header.payload_offset * SECTOR_SIZE, SEEK_SET)
    payload = file.read(PAYLOAD_SIZE)
    if len(payload) < PAYLOAD_SIZE:
        raise ValueError("file contains less data than needed (invalid payload len)")
    if sum(payload) == 0:
        raise ValueError("file not initialized - payload contains zeros only")

    # convert into header
    header = HeaderVersion1(
        header.magic,
        header.version,
        header.cipher,
        header.mode,
        header.hash,
        payload,
        header.key_bytes * 8,
        header.digest,
        header.salt,
        header.iterations,
        header.uuid,
        keys,
    )

    # check for any active key
    if all(key.active not in [KeyVersion1.Active.ENABLED, KeyVersion1.Active.ENABLED_OLD] for key in header.keys):
        # all keys are disabled
        raise ValueError("all keys are disabled")

    for key in header.keys:
        if key.active not in [KeyVersion1.Active.ENABLED, KeyVersion1.Active.ENABLED_OLD]:
            # skip inactive keys
            continue

        hash = SIGNATURE + "$".join(
            map(
                str,
                [
                    header.version,
                    header.hash,
                    header.cipher,
                    header.mode,
                    int(header.key_size),
                    key.iterations,
                    key.salt.hex(),
                    key.af.hex(),
                    header.payload.hex(),
                ],
            )
        )
        print(hash)


@dataclass(init=False)
class HeaderVersion2:
    VERSION = 0x0002


def extract_version2(file):
    file.seek(0x1000)
    json_data = file.read(32768)
    json_end = json_data.find(b"\x00")
    json_header = json.loads(json_data[:json_end])

    # print("\nWe got the following large json header:\n")
    #print(json.dumps(json_header, indent=4))

    keyslots_cnt = len(json_header['keyslots'])
    if keyslots_cnt == 0:
        raise ValueError("no keyslots founds")

    # extract first sector of the segment for entropy check
    segment_offset = int(json_header['segments']['0']['offset'])
    file.seek(segment_offset)
    first_sector = file.read(512)

    for key in json_header['keyslots']:
        print(key)
        keyslot_offset = int(json_header['keyslots'][key]['area']['offset'])
        encryption = json_header['keyslots'][key]['area']['encryption']
        stripes = int(json_header['keyslots'][key]['af']['stripes'])
        key_size = int(json_header['keyslots'][key]['key_size'])

        (cipher_type, cipher_mode) = tuple(encryption.split("-", 1))

        file.seek(keyslot_offset)
        keyslot_encrypted_data = file.read(key_size * stripes)

        hash_mode = json_header['keyslots'][key]['af']['hash']

        kdf = json_header['keyslots'][key]['kdf']['type']
        time = int(json_header['keyslots'][key]['kdf']['time'])
        memory = int(json_header['keyslots'][key]['kdf']['memory'])
        cpus = int(json_header['keyslots'][key]['kdf']['cpus'])
        salt = b64decode(json_header['keyslots'][key]['kdf']['salt'])

        hash = SIGNATURE + "$".join(
            map(
                str,
                [
                    HeaderVersion2.VERSION,
                    kdf,
                    hash_mode,
                    cipher_type,
                    cipher_mode,
                    key_size * 8,
                    f"m={memory},t={time},p={cpus}",
                    salt.hex(),
                    keyslot_encrypted_data.hex(),
                    first_sector.hex(),
                ],
            )
        )
        print(hash)


# main


if __name__ == "__main__":
    # prepare parser and parse args
    parser = ArgumentParser(description="luks2hashcat extraction tool")
    parser.add_argument("path", type=str, help="path to LUKS container")
    args = parser.parse_args()

    # prepare struct
    header_struct = Struct(">6sH")

    try:
        with open(args.path, "rb") as file:
            # read pre header
            header = file.read(header_struct.size)
            if len(header) < header_struct.size:
                parser.error("file contains less data than needed")

            # convert bytes into temporary pre header
            header = header_struct.unpack(header)
            header = TmpHeaderPre(*header)

            # check magic bytes
            magic_bytes = {
                HeaderVersion1.MAGIC,
            }
            if header.magic not in magic_bytes:
                parser.error("improper magic bytes")

            # back to start of the file
            file.seek(0, SEEK_SET)

            # extract with proper function
            try:
                mapping = {
                    HeaderVersion1.VERSION: extract_version1,
                    HeaderVersion2.VERSION: extract_version2,
                }
                extract = mapping[header.version]
                extract(file)
            except KeyError as e:
                raise ValueError("unsupported version") from e
    except IOError as e:
        parser.error(e.strerror.lower())
    except ValueError as e:
        parser.error(str(e))
