Back to blog
FILE 0x19·DECRYPTING SIGNAL DESKTOP'S DATABASE ON MACOS

Decrypting Signal Desktop's database on macOS

April 25, 2026 · signal, macos, encryption

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

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.