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

# Author......: See docs/credits.txt
# License.....: MIT
# Target......: Exodus wallet extractor
# Example.....: exodus2hashcat.py <path to exodus seed seco file>

from __future__ import annotations

import hashlib
import struct
from argparse import ArgumentParser
from base64 import b64encode
from dataclasses import dataclass
from enum import Enum
from io import BytesIO
from struct import Struct


# consts


SIGNATURE = "EXODUS"

HEADER_MAGIC = b"SECO"
HEADER_VERSION = 0
HEADER_VERSION_TAG = b"seco-v0-scrypt-aes"
HEADER_SIZE = 224

CHECKSUM_SIZE = 256 // 8

METADATA_SALT_SIZE = 32
METADATA_CIPHER_SIZE = 32
METADATA_BLOB_KEY_IV_SIZE = 12
METADATA_BLOB_KEY_AUTH_TAG_SIZE = 16
METADATA_BLOB_KEY_KEY_SIZE = 32
METADATA_BLOB_IV_SIZE = 12
METADATA_BLOB_AUTH_TAG_SIZE = 16
METADATA_SIZE = 256


# structs


@dataclass
class File:
    header: Header
    checksum: bytes
    metadata: Metadata
    blob: bytes


@dataclass
class Header:
    magic: bytes
    version: int
    version_tag: bytes
    app_name: bytes
    app_version: bytes


@dataclass(init=False)
class Metadata:
    class Cipher(str, Enum):
        AES_256_GCM = "aes-256-gcm"

    @dataclass
    class BlobKey:
        iv: bytes
        auth_tag: bytes
        key: bytes

    @dataclass
    class Blob:
        iv: bytes
        auth_tag: bytes

    salt: bytes
    n: int
    r: int
    p: int
    cipher: Cipher
    blob_key: BlobKey
    blob: Blob

    def __init__(self, salt, n, r, p, cipher, blob_key, blob):
        self.salt = salt
        self.n = n
        self.r = r
        self.p = p
        if isinstance(cipher, bytes):
            cipher = cipher.rstrip(b"\x00")
            cipher = cipher.decode()
        cipher = self.Cipher(cipher)
        self.cipher = cipher
        self.blob_key = blob_key
        self.blob = blob


# header


def read_header(file):
    # prepare structs
    partial_header_struct = Struct(">4sL4x")
    byte_struct = Struct(">B")

    # read whole header space
    file = BytesIO(file.read(HEADER_SIZE))

    # read partial header
    partial_header = file.read(partial_header_struct.size)
    if len(partial_header) < partial_header_struct.size:
        raise ValueError("file contains less data than needed")
    partial_header = partial_header_struct.unpack(partial_header)

    # read header version tag
    header_version_tag_length = file.read(byte_struct.size)
    if len(header_version_tag_length) < byte_struct.size:
        raise ValueError("file contains less data than needed")
    (header_version_tag_length,) = byte_struct.unpack(header_version_tag_length)
    header_version_tag = file.read(header_version_tag_length)
    if len(header_version_tag) < header_version_tag_length:
        raise ValueError("file contains less data than needed")

    # read header app name
    header_app_name_length = file.read(byte_struct.size)
    if len(header_app_name_length) < byte_struct.size:
        raise ValueError("file contains less data than needed")
    (header_app_name_length,) = byte_struct.unpack(header_app_name_length)
    header_app_name = file.read(header_app_name_length)
    if len(header_app_name) < header_app_name_length:
        raise ValueError("file contains less data than needed")

    # read header app version
    header_app_version_length = file.read(byte_struct.size)
    if len(header_app_version_length) < byte_struct.size:
        raise ValueError("file contains less data than needed")
    (header_app_version_length,) = byte_struct.unpack(header_app_version_length)
    header_app_version = file.read(header_app_version_length)
    if len(header_app_version) < header_app_version_length:
        raise ValueError("file contains less data than needed")

    # make header
    header = Header(*partial_header, header_version_tag, header_app_name, header_app_version)

    return header


# checksum


def read_checksum(file):
    # read checksum
    checksum = file.read(CHECKSUM_SIZE)
    if len(checksum) < CHECKSUM_SIZE:
        raise ValueError("file contains less data than needed")

    return checksum


def validate_checksum(checksum, metadata, blob):
    # prepare hash
    sha256 = hashlib.sha256()

    # update with metadata
    sha256.update(metadata.salt)
    sha256.update(struct.pack(">LLL", metadata.n, metadata.r, metadata.p))
    sha256.update(metadata.cipher.value.encode().ljust(METADATA_CIPHER_SIZE, b"\x00"))

    # update with metadata blob key
    sha256.update(metadata.blob_key.iv)
    sha256.update(metadata.blob_key.auth_tag)
    sha256.update(metadata.blob_key.key)

    # update with metadata metadata.blob
    sha256.update(metadata.blob.iv)
    sha256.update(metadata.blob.auth_tag)

    # update with metadata padding
    metadata_size = (
        METADATA_SALT_SIZE
        + struct.calcsize(">LLL")
        + METADATA_CIPHER_SIZE
        + METADATA_BLOB_KEY_IV_SIZE
        + METADATA_BLOB_KEY_AUTH_TAG_SIZE
        + METADATA_BLOB_KEY_KEY_SIZE
        + METADATA_BLOB_IV_SIZE
        + METADATA_BLOB_AUTH_TAG_SIZE
    )
    sha256.update(bytes(METADATA_SIZE - metadata_size))

    # update with blob
    sha256.update(struct.pack(">L", len(blob)))
    sha256.update(blob)

    # make digest
    digest = sha256.digest()

    # compare
    if checksum != digest:
        raise ValueError("file corrupted - checksum validation failed")


# metadata


def read_metadata(file):
    # prepare structs
    partial_metadata_struct = Struct(">" + str(METADATA_SALT_SIZE) + "sLLL" + str(METADATA_CIPHER_SIZE) + "s")
    blob_key_struct = Struct(
        ">"
        + str(METADATA_BLOB_KEY_IV_SIZE)
        + "s"
        + str(METADATA_BLOB_KEY_AUTH_TAG_SIZE)
        + "s"
        + str(METADATA_BLOB_KEY_KEY_SIZE)
        + "s"
    )
    blob_struct = Struct(">" + str(METADATA_BLOB_IV_SIZE) + "s" + str(METADATA_BLOB_AUTH_TAG_SIZE) + "s")

    # read whole metadata space
    file = BytesIO(file.read(METADATA_SIZE))

    # read partial metadata
    partial_metadata = file.read(partial_metadata_struct.size)
    if len(partial_metadata) < partial_metadata_struct.size:
        raise ValueError("file contains less data than needed")
    partial_metadata = partial_metadata_struct.unpack(partial_metadata)

    # read blob key
    blob_key = file.read(blob_key_struct.size)
    if len(blob_key) < blob_key_struct.size:
        raise ValueError("file contains less data than needed")
    blob_key = blob_key_struct.unpack(blob_key)
    blob_key = Metadata.BlobKey(*blob_key)

    # read blob
    blob = file.read(blob_struct.size)
    if len(blob) < blob_struct.size:
        raise ValueError("file contains less data than needed")
    blob = blob_struct.unpack(blob)
    blob = Metadata.Blob(*blob)

    # make metadata
    metadata = Metadata(*partial_metadata, blob_key, blob)

    return metadata


# blob


def read_blob(file):
    # prepare structs
    size_struct = Struct(">L")

    # read size
    size = file.read(size_struct.size)
    if len(size) < size_struct.size:
        raise ValueError("file contains less data than needed")
    (size,) = size_struct.unpack(size)

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

    return blob


# file


def read_file(file):
    # read header
    header = read_header(file)

    # validate header values
    if header.magic != HEADER_MAGIC:
        raise ValueError("not a SECO file")
    if (header.version != HEADER_VERSION) or (header.version_tag != HEADER_VERSION_TAG):
        raise ValueError("unsupported version")

    # read checksum
    checksum = read_checksum(file)

    # read metadata
    metadata = read_metadata(file)

    # read blob
    blob = read_blob(file)

    # validate digest
    validate_checksum(checksum, metadata, blob)

    # make file
    file = File(header, checksum, metadata, blob)

    return file


# main


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

    try:
        with open(args.path, "rb") as file:
            file = read_file(file)

        hash = ":".join(
            map(
                str,
                [
                    SIGNATURE,
                    file.metadata.n,
                    file.metadata.r,
                    file.metadata.p,
                    b64encode(file.metadata.salt).decode(),
                    b64encode(file.metadata.blob_key.iv).decode(),
                    b64encode(file.metadata.blob_key.key).decode(),
                    b64encode(file.metadata.blob_key.auth_tag).decode(),
                ],
            )
        )
        print(hash)
    except IOError as e:
        parser.error(e.strerror.lower())
    except ValueError as e:
        parser.error(str(e))
