Prepare-KHDBStorage.ps1 (publishes the KHDB) and decrypt.py (decrypts client hash exports on the provider's cracking machine) are only ever run by the tool provider, but sat next to the client scripts that every customer clones. Moved both to tools/ so the client surface (root wrappers + src/Elysium) is separate from provider tooling; packaging the module later ships only src/Elysium. Prepare-KHDBStorage.ps1 now resolves Elysium.Common.ps1 and the default ElysiumSettings.txt from the repo root (one level up) instead of its own folder. tests/test_decrypt.py imports decrypt from tools/. README and AGENTS paths updated; AGENTS' structure listing also gets the module lines back after the settings sample, where they belong. Verified: decrypt round trip passes from the new location, and tools/Prepare-KHDBStorage.ps1 -SkipUpload runs from the repo root and produces a package (manifest, 256 shards, khdb-clean.txt).
96 lines
3.9 KiB
Python
96 lines
3.9 KiB
Python
##################################################
|
|
## ____ ___ ____ _____ _ _ _____ _____ ##
|
|
## / ___/ _ \| _ \| ____| | \ | | ____|_ _| ##
|
|
## | | | | | | |_) | _| | \| | _| | | ##
|
|
## | |__| |_| | _ <| |___ _| |\ | |___ | | ##
|
|
## \____\__\_\_| \_\_____(_)_| \_|_____| |_| ##
|
|
##################################################
|
|
## Project: Elysium ##
|
|
## File: decrypt.py ##
|
|
## Support: support@cqre.net ##
|
|
##################################################
|
|
|
|
# Installation notes:
|
|
# Install PyCryptodome with "pip install pycryptodome". Must be run with python3.
|
|
|
|
import getpass
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import sys
|
|
|
|
from Crypto.Cipher import AES
|
|
from Crypto.Util.Padding import unpad
|
|
|
|
PBKDF2_ITERATIONS = 100000
|
|
|
|
|
|
def derive_keys(passphrase: str, salt: bytes, length: int) -> bytes:
|
|
# Must match .NET Rfc2898DeriveBytes(string, salt, 100000, SHA256) in Extract-NTHashes.ps1:
|
|
# UTF-8 password bytes and an HMAC-SHA256 PRF. Successive GetBytes() calls there continue one
|
|
# output stream, so its GetBytes(32) + GetBytes(32) equals the first 64 bytes here.
|
|
return hashlib.pbkdf2_hmac('sha256', passphrase.encode('utf-8'), salt, PBKDF2_ITERATIONS, length)
|
|
|
|
|
|
def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
|
|
return unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext), AES.block_size)
|
|
|
|
|
|
def decrypt_ely2(data: bytes, passphrase: str) -> bytes:
|
|
# 'ELY2' | salt(16) | iv(16) | ciphertext | HMAC-SHA256(32) over everything before it
|
|
if len(data) < 4 + 16 + 16 + 16 + 32:
|
|
raise ValueError("Encrypted data too short for ELY2 format")
|
|
body, mac = data[:-32], data[-32:]
|
|
keys = derive_keys(passphrase, data[4:20], 64)
|
|
# Verify before decrypting: CBC alone can't tell a wrong passphrase from tampered data.
|
|
if not hmac.compare_digest(mac, hmac.new(keys[32:], body, hashlib.sha256).digest()):
|
|
raise ValueError("HMAC check failed: wrong passphrase, or the file is corrupted or was modified")
|
|
return aes_cbc_decrypt(keys[:32], data[20:36], body[36:])
|
|
|
|
|
|
def decrypt_ely1(data: bytes, passphrase: str) -> bytes:
|
|
# 'ELY1' | salt(16) | iv(16) | ciphertext - no MAC, so a wrong passphrase shows up only as a padding error
|
|
if len(data) < 4 + 16 + 16:
|
|
raise ValueError("Encrypted data too short for ELY1 format")
|
|
return aes_cbc_decrypt(derive_keys(passphrase, data[4:20], 32), data[20:36], data[36:])
|
|
|
|
|
|
def decrypt_legacy(data: bytes, passphrase: str) -> bytes:
|
|
# Pre-ELY1: key = SHA-256(passphrase), iv(16) | ciphertext
|
|
if len(data) < 16:
|
|
raise ValueError("Encrypted data too short for legacy format")
|
|
return aes_cbc_decrypt(hashlib.sha256(passphrase.encode()).digest(), data[:16], data[16:])
|
|
|
|
|
|
def decrypt(data: bytes, passphrase: str) -> bytes:
|
|
if data.startswith(b"ELY2"):
|
|
return decrypt_ely2(data, passphrase)
|
|
if data.startswith(b"ELY1"):
|
|
return decrypt_ely1(data, passphrase)
|
|
return decrypt_legacy(data, passphrase)
|
|
|
|
|
|
def main():
|
|
encrypted_file_path = os.path.abspath(input("Enter the name of the encrypted file (with .enc extension): "))
|
|
if not encrypted_file_path.endswith('.enc'):
|
|
sys.exit("Expected a file with the .enc extension.")
|
|
decrypted_file_path = encrypted_file_path[:-len('.enc')] + '.zip'
|
|
|
|
passphrase = os.getenv('ELYSIUM_PASSPHRASE')
|
|
if passphrase is None:
|
|
passphrase = getpass.getpass("Passphrase not found in environment. Please enter the passphrase: ")
|
|
|
|
try:
|
|
with open(encrypted_file_path, 'rb') as encrypted_file:
|
|
decrypted_data = decrypt(encrypted_file.read(), passphrase)
|
|
with open(decrypted_file_path, 'wb') as decrypted_file:
|
|
decrypted_file.write(decrypted_data)
|
|
except Exception as e:
|
|
sys.exit(f"An error occurred: {e}")
|
|
|
|
print(f"File has been decrypted: {decrypted_file_path}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|