#!/usr/bin/env python

-- coding: utf-8 --
"""
A definitive tool to unpack custom archives that are protected by a password-
based XOR encryption scheme, then Brotli-compressed, containing a ZIP archive.

This script performs a four-stage unpacking process:

It reads the input file, separating the 2-byte salt from the ciphertext.
It derives a 32-byte encryption key using SHA-256 on the (password + salt).
It decrypts the ciphertext using a repeating XOR with the derived key.
It decompresses the decrypted data using Brotli and extracts the resulting
ZIP archive.
"""
import os
import argparse
import hashlib
import brotli
import zipfile
import io

def xor_decrypt(data, key):
"""Decrypts data using a repeating XOR key."""
decrypted_data = bytearray(data)
key_len = len(key)
for i in range(len(decrypted_data)):
decrypted_data[i] ^= key[i % key_len]
return bytes(decrypted_data)

def unpack_encrypted_archive(archive_path, password, output_dir):
"""
Decrypts, decompresses, and extracts the archive using a password.


Args:
    archive_path (str): The path to the custom encrypted archive file.
    password (str): The password to derive the decryption key.
    output_dir (str): The path to the directory for extracted files.
"""
print(f"--- Processing archive: {archive_path} ---")

try:
    print(f"[*] Reading encrypted file...")
    with open(archive_path, 'rb') as f:
        full_file_data = f.read()
        
except FileNotFoundError:
    print(f"[!] ERROR: The file '{archive_path}' was not found.")
    return
except Exception as e:
    print(f"[!] An unexpected error occurred while reading the file: {e}")
    return
    
if len(full_file_data) < 2:
    print("[!] ERROR: File is too small to contain a salt and data.")
    return

# --- Stage 1: Separate Salt and Ciphertext ---
salt = full_file_data[:2]
ciphertext = full_file_data[2:]
print(f"[*] Stage 1: Extracted 2-byte salt: {salt.hex()}")

# --- Stage 2: Derive Decryption Key from Password and Salt ---
password_bytes = password.encode('utf-8')
kdf_input = password_bytes + salt

# Use SHA-256 as the Key Derivation Function
xor_key = hashlib.sha256(kdf_input).digest()

print(f"[*] Stage 2: Derived 32-byte key from password and salt (SHA256(pass+salt)).")
print(f"   -> Key starts with: {xor_key[:8].hex()}...")

# --- Stage 3: Decrypt the Ciphertext with the XOR key ---
print(f"[*] Stage 3: Decrypting data with the derived XOR key...")
decrypted_brotli_stream = xor_decrypt(ciphertext, xor_key)
print("[+] XOR decryption complete.")

# --- Stage 4: Decompress with Brotli and Extract ZIP ---
try:
    print(f"[*] Stage 4.1: Decompressing the decrypted stream using Brotli...")
    decompressed_zip_data = brotli.decompress(decrypted_brotli_stream)
    print(f"[+] Brotli decompression successful. Decompressed size: {len(decompressed_zip_data)} bytes.")

    print(f"[*] Stage 4.2: Treating decompressed data as a ZIP archive...")
    if not decompressed_zip_data.startswith(b'\x50\x4b\x03\x04'):
        print("[!] WARNING: Decompressed data does not have the standard ZIP signature ('PK').")

    zip_buffer = io.BytesIO(decompressed_zip_data)
    with zipfile.ZipFile(zip_buffer, 'r') as zip_ref:
        print(f"[+] ZIP archive is valid. Found {zip_ref.namelist().__len__()} files to extract.")
        os.makedirs(output_dir, exist_ok=True)
        zip_ref.extractall(output_dir)
        print(f"\nSuccessfully extracted all files to: {output_dir}")

except brotli.error as e:
    print(f"\n[!] ERROR: Brotli decompression failed: {e}")
    print("This indicates the password or the decryption logic is incorrect.")
except zipfile.BadZipFile:
    print("\n[!] ERROR: The decompressed data is not a valid ZIP file.")
except Exception as e:
    print(f"\n[!] An unexpected error occurred during final extraction: {e}")
if name == 'main':
parser = argparse.ArgumentParser(
description="Unpacks archives protected with a password-based XOR-encryption scheme.",
formatter_class=argparse.RawTextHelpFormatter
)


parser.add_argument("archive_file", help="The path to the custom encrypted archive file.")
parser.add_argument("--password", required=True, help="The password for decryption.")
parser.add_argument(
    "--output",
    default=None,
    help="The directory to extract files into (defaults to a new directory named after the archive)."
)

args = parser.parse_args()

output_dir = args.output
if output_dir is None:
    base_name = os.path.basename(args.archive_file)
    output_dir = os.path.splitext(base_name)[0] + "_unpacked"

unpack_encrypted_archive(args.archive_file, args.password, output_dir)
python unpack_halva2.py unknown_file.halva2 ./MyGameAssets
--- Processing archive: unknown_file.halva2 ---
[] Stage 1: Decompressing the entire file using Brotli...
[!] ERROR: Brotli decompression failed: Decompression error: b'CL_SPACE'
This file may not be a valid Brotli stream.
PS C:\Test\Python> python unpack_halva2.py unknown_file.halva2 ./MyGameAssets
--- Processing archive: unknown_file.halva2 ---
[] Reading encrypted file...
[] Stage 1: Decrypting data with XOR key '9bb4fc3b'...
[+] XOR decryption complete.
[] Stage 2: Decompressing the decrypted stream using Brotli...
[!] ERROR: Brotli decompression failed: Decompression error: b'SIMPLE_HUFFMAN_ALPHABET'
This indicates the XOR key is likely incorrect or the file is not a valid Brotli stream after decryption.
PS C:\Test\Python> python unpack_halva2.py unknown_file.halva2 --password "123456" --output ./MyGameAssets
--- Processing archive: unknown_file.halva2 ---
[] Reading encrypted file...
[] Stage 1: Extracted 2-byte salt: 4313
[] Stage 2: Derived 32-byte key from password and salt (SHA256(pass+salt)).
-> Key starts with: 13420223152b2ea3...
[] Stage 3: Decrypting data with the derived XOR key...
[+] XOR decryption complete.
[*] Stage 4.1: Decompressing the decrypted stream using Brotli...

[!] ERROR: Brotli decompression failed: Decompression error: b'PADDING_2'
This indicates the password or the decryption logic is incorrect.