Last active
May 23, 2026 18:47
-
-
Save neuhaus/6f2c91199adf26e6f66ec161cd2bfed4 to your computer and use it in GitHub Desktop.
python port of https://github.com/fjh658/signal-decryption-tool for macOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Signal Decryption Tool for macOS | |
| Decrypts Signal's encrypted keys stored in the macOS Keychain. | |
| Ported from Rust to Python. | |
| References: | |
| - https://github.com/fjh658/signal-decryption-tool | |
| - https://github.com/electron/electron/blob/41b8fdca5c53a41eabdad9a6a75b45bda4a6f37b/shell/browser/api/electron_api_safe_storage.cc | |
| - https://chromium.googlesource.com/chromium/src/+/refs/tags/130.0.6686.2/components/os_crypt/sync/os_crypt_mac.mm | |
| """ | |
| import argparse | |
| import json | |
| import subprocess | |
| import sys | |
| from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes | |
| from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC | |
| from cryptography.hazmat.primitives import hashes, padding | |
| # ── Constants ──────────────────────────────────────────────────────────────── | |
| SALT = b"saltysalt" | |
| ITERATIONS = 1003 | |
| KEY_LENGTH = 16 # AES-128 | |
| ENCRYPTION_VERSION_PREFIX = b"v10" | |
| DEFAULT_CONFIG_PATH = f"{__import__('os').path.expanduser('~/Library/Application Support/Signal/config.json')}" | |
| SERVICE_NAME = "Signal Safe Storage" | |
| # ── Key derivation (PBKDF2-HMAC-SHA1) ──────────────────────────────────────── | |
| def derive_key(password: str) -> bytes: | |
| """Derive a 16-byte AES key from the password using PBKDF2-HMAC-SHA1.""" | |
| kdf = PBKDF2HMAC( | |
| algorithm=hashes.SHA1(), | |
| length=KEY_LENGTH, | |
| salt=SALT, | |
| iterations=ITERATIONS, | |
| ) | |
| return kdf.derive(password.encode("utf-8")) | |
| # ── AES-128-CBC with fixed IV (16 spaces) ─────────────────────────────────── | |
| def encrypt_aes(plaintext: str, aes_key: bytes) -> bytes: | |
| """Encrypt plaintext with AES-128-CBC and fixed IV of 16 space characters.""" | |
| iv = b" " * KEY_LENGTH | |
| cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv)) | |
| encryptor = cipher.encryptor() | |
| padder = padding.PKCS7(128).padder() | |
| padded = padder.update(plaintext.encode("utf-8")) + padder.finalize() | |
| return encryptor.update(padded) + encryptor.finalize() | |
| def decrypt_aes(encrypted_data: bytes, aes_key: bytes) -> str: | |
| """Decrypt AES-128-CBC ciphertext with fixed IV of 16 space characters.""" | |
| iv = b" " * KEY_LENGTH | |
| cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv)) | |
| decryptor = cipher.decryptor() | |
| padded = decryptor.update(encrypted_data) + decryptor.finalize() | |
| try: | |
| unpadder = padding.PKCS7(128).unpadder() | |
| plaintext = unpadder.update(padded) + unpadder.finalize() | |
| return plaintext.decode("utf-8") | |
| except (ValueError, UnicodeDecodeError): | |
| print("Error: Decryption failed. The secure storage key might be incorrect, or the data is corrupted.", file=sys.stderr) | |
| sys.exit(1) | |
| # ── Keychain access ────────────────────────────────────────────────────────── | |
| def get_keychain_password(service: str, account: str) -> str | None: | |
| """Retrieve a password from macOS Keychain using the `security` CLI.""" | |
| try: | |
| result = subprocess.run( | |
| [ | |
| "security", "find-generic-password", | |
| "-s", service, | |
| "-a", account, | |
| "-w", | |
| ], | |
| capture_output=True, | |
| text=True, | |
| timeout=10, | |
| ) | |
| if result.returncode == 0: | |
| return result.stdout.strip() | |
| except (FileNotFoundError, subprocess.TimeoutExpired): | |
| pass | |
| return None | |
| def get_secure_password() -> str: | |
| """Retrieve the Signal secure storage password, trying 'Signal Key' then 'Signal'.""" | |
| password = get_keychain_password(SERVICE_NAME, "Signal Key") | |
| if password is None: | |
| password = get_keychain_password(SERVICE_NAME, "Signal") | |
| if password is None: | |
| print("Error: Could not find secure storage password in Keychain.", file=sys.stderr) | |
| sys.exit(1) | |
| return password | |
| # ── Config loading ─────────────────────────────────────────────────────────── | |
| def load_encrypted_key(config_path: str) -> str: | |
| """Load the encrypted key from Signal's config.json.""" | |
| try: | |
| with open(config_path, "r") as f: | |
| config = json.load(f) | |
| except FileNotFoundError: | |
| print(f"Error: Config file not found at '{config_path}'", file=sys.stderr) | |
| sys.exit(1) | |
| except json.JSONDecodeError: | |
| print(f"Error: Malformed JSON in config file at '{config_path}'", file=sys.stderr) | |
| sys.exit(1) | |
| encrypted_key = config.get("encryptedKey") | |
| if not encrypted_key: | |
| print("Error: 'encryptedKey' not found in config.json.", file=sys.stderr) | |
| sys.exit(1) | |
| return encrypted_key | |
| # ── Main decryption ────────────────────────────────────────────────────────── | |
| def decrypt_signal_key(encrypted_hex: str, aes_key: bytes) -> str: | |
| """Decode hex, strip v10 prefix, and decrypt.""" | |
| try: | |
| data = bytes.fromhex(encrypted_hex) | |
| except ValueError: | |
| print("Error: The encrypted key is not a valid hex string.", file=sys.stderr) | |
| sys.exit(1) | |
| if not data.startswith(ENCRYPTION_VERSION_PREFIX): | |
| print(f"Error: Invalid encryption version prefix (expected 'v10').", file=sys.stderr) | |
| sys.exit(1) | |
| ciphertext = data[len(ENCRYPTION_VERSION_PREFIX):] | |
| return decrypt_aes(ciphertext, aes_key) | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Decrypt Signal's encrypted keys on macOS.", | |
| ) | |
| parser.add_argument( | |
| "-c", "--config", | |
| default=None, | |
| help="Path to config.json (default: ~/Library/Application Support/Signal/config.json)", | |
| ) | |
| parser.add_argument( | |
| "-k", "--key", | |
| default=None, | |
| help="Provide an encrypted key directly (hex string)", | |
| ) | |
| parser.add_argument( | |
| "-p", "--print-key", | |
| action="store_true", | |
| help="Print the secure storage key (use with caution)", | |
| ) | |
| parser.add_argument( | |
| "--version", | |
| action="version", | |
| version="SignalDecryption 1.0.0 (Python, macOS)", | |
| ) | |
| args = parser.parse_args() | |
| # 1. Get the secure password from Keychain | |
| secure_password = get_secure_password() | |
| if args.print_key: | |
| print(f"Secure password retrieved: {secure_password}") | |
| # 2. Derive the AES key | |
| aes_key = derive_key(secure_password) | |
| # 3. Get the encrypted key (from CLI or config file) | |
| if args.key: | |
| encrypted_key_str = args.key | |
| print("Using directly provided encrypted key.") | |
| else: | |
| config_path = args.config or DEFAULT_CONFIG_PATH | |
| print(f"Using config path: {config_path}") | |
| encrypted_key_str = load_encrypted_key(config_path) | |
| # 4. Decrypt | |
| print(f"Encrypted key: {encrypted_key_str}") | |
| decrypted = decrypt_signal_key(encrypted_key_str, aes_key) | |
| print(f"Decrypted key: {decrypted}") | |
| if __name__ == "__main__": | |
| main() |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This was oneshotted with qwen 3.6 27b and hermes agent, it required only a simple fix.
This is for use with Signal desktop on macOS.
How to use:
Library/Application Support/Signal/sql/db.sqlitebrew install sqlcipher