Have you ever thought about what installing Signal Desktop on your work laptop actually exposes?
Signal’s whole pitch is end-to-end encryption, so nobody in the middle reads your messages. That’s true, and it’s also only a claim about the wire. It says nothing about the two ends. On a machine you don’t own (a corporate laptop with MDM, an EDR agent, an IT team that holds admin, a backup client quietly copying your home directory) “the end” is a crowded place.
Here’s the uncomfortable part. Every message Signal Desktop has ever shown you is sitting in a local database, and the key that decrypts it is guarded by nothing stronger than your logged-in session. Anyone who can run code as you can turn your entire message history into a plain SQL table: your IT department, an endpoint agent, malware that landed on the box, a forensic image captured while you’re logged in. The encryption you’re trusting protects that database when the machine is off, not from anyone who is already you.
I found this out chasing a genuinely dumb question: did my partner ever
finish telling me which dinner recipes sounded good, or did the thread
get derailed? Scrolling three days up in Signal is misery. It turns out
the Signal Desktop database is just SQLite (SQLCipher, technically), and
once you decrypt it you can answer that with a SELECT. The same five
minutes that settled the recipe question is the entire exposure of
putting Signal on a machine you don’t control.
This post walks through decrypting the local Signal Desktop database on macOS and querying your own message history, which is also, exactly, what someone with access to your endpoint can read.
In fairness, some of the onus for me even thinking about the work-laptop angle belongs to Martin Wendiggensen, who put it best: “I mean… technically you found out about it because your crazy German friend kept telling everyone not to use Signal on their work laptops or any laptops for that matter.” He’s right. The recipe question is what got me into the database; the German friend’s paranoid warning is what made me look at where the database was sitting.
A note on scope before anyone gets excited: this only works on your own machine, against your own data, and it requires your macOS login to unlock the keychain. There is no remote magic here; it’s the same access you already have by opening the Signal app. That’s the whole point. On your personal Mac, “whoever can unlock your session” is you and only you; on a managed work machine, it’s a longer list. Don’t point this at anyone else’s computer.
Signal Desktop keeps everything in a SQLCipher database:
~/Library/Application Support/Signal/sql/db.sqlite
SQLCipher is SQLite with transparent AES encryption. The 256-bit key for that database is itself stored, encrypted, in a small JSON file:
~/Library/Application Support/Signal/config.json
That file has an encryptedKey field. The encryption wrapping it is
Electron’s safeStorage, which on macOS derives its key from a secret
in the login keychain named “Signal Safe Storage”. So the chain is:
macOS keychain -> safeStorage key -> decrypts encryptedKey
-> SQLCipher key -> decrypts db.sqlite
We just have to walk that chain.
brew install sqlcipher
python3 -m pip install pycryptodome
sqlcipher opens the encrypted database; pycryptodome handles the
AES-CBC step that unwraps the key.
security find-generic-password -ws "Signal Safe Storage"
The first time you run this, macOS pops a dialog asking you to allow access to the keychain item. Click Allow (or Always Allow). This is the login-password gate; it’s why nobody can do this without already being you, sitting at your unlocked machine.
You’ll get back a short base64-looking string. Hold onto it.
python3 -c "import json,os; \
p=os.path.expanduser('~/Library/Application Support/Signal/config.json'); \
print(json.load(open(p))['encryptedKey'])"
The value is hex. Decoded, it starts with the ASCII bytes v10, which
is Electron’s tag for “this was encrypted with safeStorage.” Everything
after that tag is AES-128-CBC ciphertext.
The v10 scheme is the same one Chromium uses for cookies, so the
parameters are well known and fixed:
saltysalt1003v10 prefix before decryptingDrop this into signal_key.py:
import hashlib
import json
import os
from Crypto.Cipher import AES
HOME = os.path.expanduser("~")
cfg = json.load(open(f"{HOME}/Library/Application Support/Signal/config.json"))
enc = bytes.fromhex(cfg["encryptedKey"])
assert enc[:3] == b"v10", enc[:3]
ciphertext = enc[3:]
# Paste the string from: security find-generic-password -ws "Signal Safe Storage"
password = b"REPLACE_WITH_KEYCHAIN_PASSWORD"
key = hashlib.pbkdf2_hmac("sha1", password, b"saltysalt", 1003, dklen=16)
iv = b" " * 16
dec = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
dec = dec[:-dec[-1]] # strip PKCS7 padding
print(dec.decode()) # 64 hex chars = the SQLCipher key
You should get a 64-character hex string. That’s the raw SQLCipher key.
Signal keeps the database open, and there may be a -wal write-ahead
log alongside it. Don’t poke at the live files; copy them first:
SRC="$HOME/Library/Application Support/Signal/sql"
WORK="/tmp/signal-work"
mkdir -p "$WORK"
cp "$SRC/db.sqlite" "$WORK/"
[ -f "$SRC/db.sqlite-wal" ] && cp "$SRC/db.sqlite-wal" "$WORK/"
[ -f "$SRC/db.sqlite-shm" ] && cp "$SRC/db.sqlite-shm" "$WORK/"
Now open the copy. SQLCipher wants the key as a raw hex blob, so wrap it
as x'...':
DBKEY="<the 64 hex chars from step 3>"
sqlcipher "$WORK/db.sqlite" "PRAGMA key = \"x'$DBKEY'\"; \
SELECT count(*) FROM sqlite_master;"
If that prints a number instead of an error, you’re in. If it complains about the file being encrypted or not a database, the key is wrong. Recheck the keychain password and the padding strip.
Two tables carry the weight:
conversations: one row per person or group. Useful columns:
id, type, name, profileName, profileFamilyName, e164.messages: one row per message. Useful columns: conversationId,
body, sent_at (Unix epoch in milliseconds), and type, which
is outgoing for messages you sent and incoming for ones you
received.I like to drive it from a .sql file so the dot-commands and the key
pragma don’t fight on the command line:
-- query.sql
PRAGMA key = "x'PUT_YOUR_HEX_KEY_HERE'";
.mode column
.headers on
-- Find a conversation by name
SELECT id, name, profileName, e164
FROM conversations
WHERE (COALESCE(name,'') || ' ' || COALESCE(profileName,''))
LIKE '%amanda%';
sqlcipher "$WORK/db.sqlite" < query.sql
Once you have the conversationId, searching that thread is ordinary
SQL. Here’s the query that actually answered my recipe question. It
converts the millisecond timestamp to local time and relabels the
direction so it reads like a transcript:
PRAGMA key = "x'PUT_YOUR_HEX_KEY_HERE'";
.mode list
.headers off
SELECT datetime(sent_at/1000, 'unixepoch', 'localtime') AS ts,
CASE type WHEN 'outgoing' THEN 'ME'
WHEN 'incoming' THEN 'THEM'
ELSE type END AS who,
substr(replace(body, char(10), ' '), 1, 200) AS body
FROM messages
WHERE conversationId = 'YOUR-CONVERSATION-ID'
AND body LIKE '%recipe%'
ORDER BY sent_at;
A few patterns that come in handy:
-- Dump an entire day of a thread, full text
SELECT datetime(sent_at/1000,'unixepoch','localtime'),
type, body
FROM messages
WHERE conversationId = 'YOUR-CONVERSATION-ID'
AND sent_at >= strftime('%s','2026-08-15')*1000
AND sent_at < strftime('%s','2026-08-16')*1000
ORDER BY sent_at;
-- Which conversations are actually active?
SELECT c.name, c.profileName, count(m.id) AS msgs,
datetime(max(m.sent_at)/1000,'unixepoch','localtime') AS last
FROM conversations c
JOIN messages m ON m.conversationId = c.id
GROUP BY c.id
ORDER BY msgs DESC
LIMIT 20;
Rows with a NULL body are usually attachments, reactions, or
call events rather than text, so filter them out with
WHERE body IS NOT NULL if they’re noise.
The working copy is your entire message history sitting in plaintext- reachable form (the copy is still encrypted, but the key is right there next to it). When you’re done, delete it and the key file:
rm -rf /tmp/signal-work signal_key.py query.sql
Nothing here is a Signal vulnerability. Local-first apps have to store a key the app itself can read at rest, and Electron’s answer is to lean on the OS keychain, gated by your login. Anyone with your unlocked user session and login password already has this access; the encryption is there to protect the data if the disk is pulled or the machine is off, not to keep you out of your own messages.
Which loops back to where this started. On a machine you own, that “anyone” is just you. On a work laptop it’s you plus everyone who can act as you: IT with admin, the EDR agent running as your user, whoever images the disk during an incident response. None of them need to break Signal’s encryption. They inherit your session, and your session is the only lock on the key. That’s the real reason to think twice before signing into Signal on hardware you don’t control.
The same idea ports to other platforms, only the keychain step changes:
peanuts password when no keyring is available.The PBKDF2/v10 unwrap in Step 3 is identical to how you’d decrypt
Chrome or Edge cookies on the same OS, so if you’ve ever done browser
cookie forensics, this will feel familiar.
Happy grepping. Responsibly, and only on your own data.