Decrypting Signal Desktop's database on macOS
I wanted Signal Desktop's message history available for indexing in my personal search. Signal Desktop stores messages in a SQLCipher database, and the key for that database lives in the macOS keychain, indirectly. Here's the recipe that worked, after a few wrong turns.
What Signal Desktop stores
- Database:
~/Library/Application Support/Signal/sql/db.sqlite - Encryption: SQLCipher (AES-256 in CBC, PBKDF2 key derivation)
- The actual 32-byte key isn't in the keychain. Instead, an
encrypted blob is in
~/Library/Application Support/Signal/config.jsonunderencryptedKey, and the key to decrypt that blob is in the macOS keychain.
So you don't fetch the SQLCipher key directly. You fetch a key-
encryption-key from the keychain, decrypt the blob from
config.json to get the SQLCipher key, then use that key to open
db.sqlite.
Getting the key-encryption-key
The keychain item is named Signal Safe Storage, account
Signal Key. Stock macOS prompts for permission the first time a
script tries to read it.
security find-generic-password -w -s "Signal Safe Storage" -a "Signal Key"
That returns a base64 string — and here's the first wrong turn I took. I assumed the actual key was the base64-decoded bytes of that string. It isn't. The Chromium safeStorage v10 recipe used by Signal Desktop treats the keychain value as a UTF-8 string of base64 text, and uses that string — not the decoded bytes — as the password input to PBKDF2.
import base64
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
keychain_value = "<base64 string from `security find-generic-password`>"
# WRONG: don't decode first
# password_bytes = base64.b64decode(keychain_value)
# RIGHT: use the base64 string itself as the password
password_bytes = keychain_value.encode("utf-8")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA1(),
length=16,
salt=b"saltysalt", # Chromium's hardcoded salt
iterations=1003, # Chromium's macOS iteration count
)
kek = kdf.derive(password_bytes)
The constants (saltysalt, 1003 iterations, SHA-1, 16 byte key)
come from Chromium's safeStorage implementation, which Signal
Desktop inherits via Electron. They're not Signal-specific.
Decrypting the blob to get the SQLCipher key
config.json has encryptedKey: "v10<hex blob>". The v10 prefix
indicates the Chromium safeStorage version. Strip it, hex-decode
the rest, decrypt with AES-128-CBC using the KEK from above and
the same hardcoded IV (b" " * 16):
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
blob_hex = config["encryptedKey"][len("v10"):]
blob = bytes.fromhex(blob_hex)
cipher = Cipher(algorithms.AES(kek), modes.CBC(b" " * 16))
decryptor = cipher.decryptor()
padded = decryptor.update(blob) + decryptor.finalize()
# PKCS#7 unpad
pad_len = padded[-1]
sqlcipher_key_hex = padded[:-pad_len].decode("ascii")
The decrypted value is the SQLCipher key as a hex string. Cache it to disk (mode 0600) so you don't have to prompt the keychain again every minute.
Opening the database
With sqlcipher3-binary:
import sqlcipher3 as sqlite3
conn = sqlite3.connect("/path/to/db.sqlite")
conn.execute(f"PRAGMA key = \"x'{sqlcipher_key_hex}'\"") # raw hex form
for row in conn.execute("SELECT id, received_at, body FROM messages LIMIT 5"):
print(row)
The PRAGMA key = "x'<hex>'" form tells SQLCipher the key is raw
hex (not a passphrase to be PBKDF2'd again).
What I'd do differently
Two things.
One: cache the SQLCipher key in a 0600 file under your sync directory after the one-time keychain unlock. Otherwise every script run prompts for keychain access, and if you're trying to run this from a non-interactive context (cron, SSH session, systemd unit), the prompt will silently fail and your script will think the key is wrong.
Two: the keychain-string-as-password trap cost me a couple hours. Anytime you're decoding a value out of the macOS keychain and feeding it into something cryptographic, look at how the consumer treats the value — not how the value looks. Base64 strings look like they want to be decoded. Sometimes they don't.