diff --git a/tools/exodus2hashcat.py b/tools/exodus2hashcat.py index e81d42d5e..e9472c629 100755 --- a/tools/exodus2hashcat.py +++ b/tools/exodus2hashcat.py @@ -1,65 +1,335 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # Author......: See docs/credits.txt # License.....: MIT # Target......: Exodus wallet extractor # Example.....: exodus2hashcat.py -import binascii -import sys +from __future__ import annotations + import hashlib -from Crypto.Cipher import AES -import base64 -import os.path - -METADATA_LEN = 256 -HEADER_LEN = 224 -CRC_LEN = 32 -LEN_BLOB_STORED = 4 - -if len(sys.argv) != 2 : - print("Error, usage exodus2hashcat.py ") - sys.exit(1) - -with open(sys.argv[1],'rb') as fd: - seedBuffer = fd.read() - -#Basic check -if not seedBuffer[0:4].decode("utf8").startswith("SECO"): - print("Not A SECO exodus header magic") - sys.exit(1) - -salt = seedBuffer[0x100:0x120] - -n = int.from_bytes(seedBuffer[0x120:0x124],"big") -r = int.from_bytes(seedBuffer[0x124:0x128],"big") -p = int.from_bytes(seedBuffer[0x128:0x12c],"big") - -#Basic check -if n!=16384 or r !=8 or p != 1: - print("Warning,unexpected scrypt N,r,p values") - -if os.path.getsize(sys.argv[1]) != METADATA_LEN + HEADER_LEN + CRC_LEN + LEN_BLOB_STORED+ int.from_bytes(seedBuffer[0x200:0x204],"big"): - print(os.path.getsize(sys.argv[1])) - print( METADATA_LEN + HEADER_LEN + int.from_bytes(seedBuffer[0x200:0x204],"big")) - print("Error file size") - sys.argv[1] - -#Check integrity -m = hashlib.sha256() -m.update(seedBuffer[HEADER_LEN+CRC_LEN:]) -if m.digest() != seedBuffer[HEADER_LEN:HEADER_LEN+CRC_LEN]: - print("SECO file seems corrupted") - sys.exit(1) - -#Check aes-gcm string -cipher = seedBuffer[0x12c:0x138] -if binascii.hexlify(cipher) != b"6165732d3235362d67636d00": - print("Error aes-256-gcm") - sys.exit(1) - -iv = seedBuffer[0x14c:0x158] -authTag = seedBuffer[0x158:0x168] -key = seedBuffer[0x168:0x188] - -print("EXODUS:"+str(n)+":"+str(r)+":"+str(p)+":"+base64.b64encode(salt).decode("utf8")+":"+base64.b64encode(iv).decode("utf8")+":"+base64.b64encode(key).decode("utf8")+":"+base64.b64encode(authTag).decode("utf8")) +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))