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

Decrypting Signal Desktop's database on macOS

Back to blog
FILE 0x19·DECRYPTING SIGNAL DESKTOP'S DATABASE ON MACOS
Back to blog
FILE 0x19·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

  • 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.json under encryptedKey, 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.