1 Title: New and improved sqlcipher in Debian for accessing Signal database
2 Tags: english, debian, sikkerhet, surveillance
5 <p>For a while now I wanted to have direct access to the
6 <a href="https://signal.org/">Signal</a> database of messages and
7 channels of my Desktop edition of Signal. I prefer the enforced end
8 to end encryption of Signal these days for my communication with
9 friends and family, to increase the level of safety and privacy as
10 well as raising the cost of the mass surveillance government and
11 non-government entities practice these days. In August I came across
13 <a href="https://www.yoranbrondsema.com/post/the-guide-to-extracting-statistics-from-your-signal-conversations/">recipe
14 on how to use sqlcipher to extract statistics from the Signal
15 database</a> explaining how to do this. Unfortunately this did not
16 work with the version of sqlcipher in Debian. The
17 <a href="http://tracker.debian.org/sqlcipher/">sqlcipher</a>
18 package is a "fork" of the sqlite package with added support for
19 encrypted databases. Sadly the current Debian maintainer
20 <a href="https://bugs.debian.org/961598">announced more than three
21 years ago that he did not have time to maintain sqlcipher</a>, so it
22 seemed unlikely to be upgraded by the maintainer. I was reluctant to
23 take on the job myself, as I have very limited experience maintaining
24 shared libraries in Debian. After waiting and hoping for a few
25 months, I gave up the last week, and set out to update the package. In
26 the process I orphaned it to make it more obvious for the next person
27 looking at it that the package need proper maintenance.</p>
29 <p>The version in Debian was around five years old, and quite a lot of
30 changes had taken place upstream into the Debian maintenance git
31 repository. After spending a few days importing the new upstream
32 versions, realising that upstream did not care much for SONAME
33 versioning as I saw library symbols being both added and removed with
34 minor version number changes to the project, I concluded that I had to
35 do a SONAME bump of the library package to avoid surprising the
36 reverse dependencies. I even added a simple
37 autopkgtest script to ensure the package work as intended. Dug deep
38 into the hole of learning shared library maintenance, I set out a few
39 days ago to upload the new version to Debian experimental to see what
40 the quality assurance framework in Debian had to say about the result.
41 The feedback told me the pacakge was not too shabby, and yesterday I
42 uploaded the latest version to Debian unstable. It should enter
43 testing today or tomorrow, perhaps delayed by
44 <a href="https://bugs.debian.org/1055812">a small library
47 <p>Armed with a new version of sqlcipher, I can now have a look at the
48 SQL database in ~/.config/Signal/sql/db.sqlite. First, one need to
49 fetch the encryption key from the Signal configuration using this
50 simple JSON extraction command:</p>
52 <pre>/usr/bin/jq -r '."key"' ~/.config/Signal/config.json</pre>
54 <p>Assuming the result from that command is 'secretkey', which is a
55 hexadecimal number representing the key used to encrypt the database.
56 Next, one can now connect to the database and inject the encryption
57 key for access via SQL to fetch information from the database. Here
58 is an example dumping the database structure:</p>
61 % sqlcipher ~/.config/Signal/sql/db.sqlite
62 sqlite> PRAGMA key = "x'secretkey'";
64 CREATE TABLE sqlite_stat1(tbl,idx,stat);
65 CREATE TABLE conversations(
66 id STRING PRIMARY KEY ASC,
74 , profileFamilyName TEXT, profileFullName TEXT, e164 TEXT, serviceId TEXT, groupId TEXT, profileLastFetchedAt INTEGER);
75 CREATE TABLE identityKeys(
76 id STRING PRIMARY KEY ASC,
80 id STRING PRIMARY KEY ASC,
83 CREATE TABLE sessions(
87 , ourServiceId STRING, serviceId STRING);
88 CREATE TABLE attachment_downloads(
89 id STRING primary key,
94 CREATE TABLE sticker_packs(
99 coverStickerId INTEGER,
101 downloadAttempts INTEGER,
105 stickerCount INTEGER,
107 , attemptedStatus STRING, position INTEGER DEFAULT 0 NOT NULL, storageID STRING, storageVersion INTEGER, storageUnknownFields BLOB, storageNeedsSync
108 INTEGER DEFAULT 0 NOT NULL);
109 CREATE TABLE stickers(
111 packId TEXT NOT NULL,
120 PRIMARY KEY (id, packId),
121 CONSTRAINT stickers_fk
123 REFERENCES sticker_packs(id)
126 CREATE TABLE sticker_references(
129 CONSTRAINT sticker_references_fk
131 REFERENCES sticker_packs(id)
135 shortName TEXT PRIMARY KEY,
138 CREATE TABLE messages(
139 rowid INTEGER PRIMARY KEY ASC,
145 schemaVersion INTEGER,
146 conversationId STRING,
149 hasAttachments INTEGER,
150 hasFileAttachments INTEGER,
151 hasVisualMediaAttachments INTEGER,
153 expirationStartTimestamp INTEGER,
156 messageTimer INTEGER,
157 messageTimerStart INTEGER,
158 messageTimerExpiresAt INTEGER,
161 sourceServiceId TEXT, serverGuid STRING NULL, sourceDevice INTEGER, storyId STRING, isStory INTEGER
162 GENERATED ALWAYS AS (type IS 'story'), isChangeCreatedByUs INTEGER NOT NULL DEFAULT 0, isTimerChangeFromSync INTEGER
163 GENERATED ALWAYS AS (
164 json_extract(json, '$.expirationTimerUpdate.fromSync') IS 1
165 ), seenStatus NUMBER default 0, storyDistributionListId STRING, expiresAt INT
168 expirationStartTimestamp + (expireTimer * 1000),
170 )), shouldAffectActivity INTEGER
171 GENERATED ALWAYS AS (
175 'change-number-notification',
176 'contact-removed-notification',
177 'conversation-merge',
178 'group-v1-migration',
180 'message-history-unsynced',
183 'universal-timer-notification',
186 ), shouldAffectPreview INTEGER
187 GENERATED ALWAYS AS (
191 'change-number-notification',
192 'contact-removed-notification',
193 'conversation-merge',
194 'group-v1-migration',
196 'message-history-unsynced',
199 'universal-timer-notification',
202 ), isUserInitiatedMessage INTEGER
203 GENERATED ALWAYS AS (
207 'change-number-notification',
208 'contact-removed-notification',
209 'conversation-merge',
210 'group-v1-migration',
213 'message-history-unsynced',
216 'universal-timer-notification',
219 ), mentionsMe INTEGER NOT NULL DEFAULT 0, isGroupLeaveEvent INTEGER
220 GENERATED ALWAYS AS (
221 type IS 'group-v2-change' AND
222 json_array_length(json_extract(json, '$.groupV2Change.details')) IS 1 AND
223 json_extract(json, '$.groupV2Change.details[0].type') IS 'member-remove' AND
224 json_extract(json, '$.groupV2Change.from') IS NOT NULL AND
225 json_extract(json, '$.groupV2Change.from') IS json_extract(json, '$.groupV2Change.details[0].aci')
226 ), isGroupLeaveEventFromOther INTEGER
227 GENERATED ALWAYS AS (
228 isGroupLeaveEvent IS 1
230 isChangeCreatedByUs IS 0
232 GENERATED ALWAYS AS (
233 json_extract(json, '$.callId')
235 CREATE TABLE sqlite_stat4(tbl,idx,neq,nlt,ndlt,sample);
238 queueType TEXT STRING NOT NULL,
239 timestamp INTEGER NOT NULL,
242 CREATE TABLE reactions(
243 conversationId STRING,
246 messageReceivedAt INTEGER,
247 targetAuthorAci STRING,
248 targetTimestamp INTEGER,
251 CREATE TABLE senderKeys(
252 id TEXT PRIMARY KEY NOT NULL,
253 senderId TEXT NOT NULL,
254 distributionId TEXT NOT NULL,
256 lastUpdatedDate NUMBER NOT NULL
258 CREATE TABLE unprocessed(
259 id STRING PRIMARY KEY ASC,
266 serverTimestamp INTEGER,
267 sourceServiceId STRING
268 , serverGuid STRING NULL, sourceDevice INTEGER, receivedAtCounter INTEGER, urgent INTEGER, story INTEGER);
269 CREATE TABLE sendLogPayloads(
270 id INTEGER PRIMARY KEY ASC,
272 timestamp INTEGER NOT NULL,
273 contentHint INTEGER NOT NULL,
275 , urgent INTEGER, hasPniSignatureMessage INTEGER DEFAULT 0 NOT NULL);
276 CREATE TABLE sendLogRecipients(
277 payloadId INTEGER NOT NULL,
279 recipientServiceId STRING NOT NULL,
280 deviceId INTEGER NOT NULL,
282 PRIMARY KEY (payloadId, recipientServiceId, deviceId),
284 CONSTRAINT sendLogRecipientsForeignKey
285 FOREIGN KEY (payloadId)
286 REFERENCES sendLogPayloads(id)
289 CREATE TABLE sendLogMessageIds(
290 payloadId INTEGER NOT NULL,
292 messageId STRING NOT NULL,
294 PRIMARY KEY (payloadId, messageId),
296 CONSTRAINT sendLogMessageIdsForeignKey
297 FOREIGN KEY (payloadId)
298 REFERENCES sendLogPayloads(id)
301 CREATE TABLE preKeys(
302 id STRING PRIMARY KEY ASC,
304 , ourServiceId NUMBER
305 GENERATED ALWAYS AS (json_extract(json, '$.ourServiceId')));
306 CREATE TABLE signedPreKeys(
307 id STRING PRIMARY KEY ASC,
309 , ourServiceId NUMBER
310 GENERATED ALWAYS AS (json_extract(json, '$.ourServiceId')));
313 category TEXT NOT NULL,
315 descriptionTemplate TEXT NOT NULL
317 CREATE TABLE badgeImageFiles(
318 badgeId TEXT REFERENCES badges(id)
321 'order' INTEGER NOT NULL,
326 CREATE TABLE storyReads (
327 authorId STRING NOT NULL,
328 conversationId STRING NOT NULL,
329 storyId STRING NOT NULL,
330 storyReadDate NUMBER NOT NULL,
332 PRIMARY KEY (authorId, storyId)
334 CREATE TABLE storyDistributions(
335 id STRING PRIMARY KEY NOT NULL,
338 senderKeyInfoJson STRING
339 , deletedAtTimestamp INTEGER, allowsReplies INTEGER, isBlockList INTEGER, storageID STRING, storageVersion INTEGER, storageUnknownFields BLOB, storageNeedsSync INTEGER);
340 CREATE TABLE storyDistributionMembers(
341 listId STRING NOT NULL REFERENCES storyDistributions(id)
344 serviceId STRING NOT NULL,
346 PRIMARY KEY (listId, serviceId)
348 CREATE TABLE uninstalled_sticker_packs (
349 id STRING NOT NULL PRIMARY KEY,
350 uninstalledAt NUMBER NOT NULL,
352 storageVersion NUMBER,
353 storageUnknownFields BLOB,
354 storageNeedsSync INTEGER NOT NULL
356 CREATE TABLE groupCallRingCancellations(
357 ringId INTEGER PRIMARY KEY,
358 createdAt INTEGER NOT NULL
360 CREATE TABLE IF NOT EXISTS 'messages_fts_data'(id INTEGER PRIMARY KEY, block BLOB);
361 CREATE TABLE IF NOT EXISTS 'messages_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
362 CREATE TABLE IF NOT EXISTS 'messages_fts_content'(id INTEGER PRIMARY KEY, c0);
363 CREATE TABLE IF NOT EXISTS 'messages_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
364 CREATE TABLE IF NOT EXISTS 'messages_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
365 CREATE TABLE edited_messages(
366 messageId STRING REFERENCES messages(id)
370 , conversationId STRING);
371 CREATE TABLE mentions (
372 messageId REFERENCES messages(id) ON DELETE CASCADE,
377 CREATE TABLE kyberPreKeys(
378 id STRING PRIMARY KEY NOT NULL,
379 json TEXT NOT NULL, ourServiceId NUMBER
380 GENERATED ALWAYS AS (json_extract(json, '$.ourServiceId')));
381 CREATE TABLE callsHistory (
382 callId TEXT PRIMARY KEY,
383 peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId
384 ringerId TEXT DEFAULT NULL, -- ringer uuid
385 mode TEXT NOT NULL, -- enum "Direct" | "Group"
386 type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group"
387 direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing
388 -- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted"
389 -- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted"
390 status TEXT NOT NULL,
391 timestamp INTEGER NOT NULL,
392 UNIQUE (callId, peerId) ON CONFLICT FAIL
394 [ dropped all indexes to save space in this blog post ]
395 CREATE TRIGGER messages_on_view_once_update AFTER UPDATE ON messages
397 new.body IS NOT NULL AND new.isViewOnce = 1
399 DELETE FROM messages_fts WHERE rowid = old.rowid;
401 CREATE TRIGGER messages_on_insert AFTER INSERT ON messages
402 WHEN new.isViewOnce IS NOT 1 AND new.storyId IS NULL
404 INSERT INTO messages_fts
407 (new.rowid, new.body);
409 CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
410 DELETE FROM messages_fts WHERE rowid = old.rowid;
411 DELETE FROM sendLogPayloads WHERE id IN (
412 SELECT payloadId FROM sendLogMessageIds
413 WHERE messageId = old.id
415 DELETE FROM reactions WHERE rowid IN (
416 SELECT rowid FROM reactions
417 WHERE messageId = old.id
419 DELETE FROM storyReads WHERE storyId = old.storyId;
421 CREATE VIRTUAL TABLE messages_fts USING fts5(
423 tokenize = 'signal_tokenizer'
425 CREATE TRIGGER messages_on_update AFTER UPDATE ON messages
427 (new.body IS NULL OR old.body IS NOT new.body) AND
428 new.isViewOnce IS NOT 1 AND new.storyId IS NULL
430 DELETE FROM messages_fts WHERE rowid = old.rowid;
431 INSERT INTO messages_fts
434 (new.rowid, new.body);
436 CREATE TRIGGER messages_on_insert_insert_mentions AFTER INSERT ON messages
438 INSERT INTO mentions (messageId, mentionAci, start, length)
440 SELECT messages.id, bodyRanges.value ->> 'mentionAci' as mentionAci,
441 bodyRanges.value ->> 'start' as start,
442 bodyRanges.value ->> 'length' as length
443 FROM messages, json_each(messages.json ->> 'bodyRanges') as bodyRanges
444 WHERE bodyRanges.value ->> 'mentionAci' IS NOT NULL
446 AND messages.id = new.id;
448 CREATE TRIGGER messages_on_update_update_mentions AFTER UPDATE ON messages
450 DELETE FROM mentions WHERE messageId = new.id;
451 INSERT INTO mentions (messageId, mentionAci, start, length)
453 SELECT messages.id, bodyRanges.value ->> 'mentionAci' as mentionAci,
454 bodyRanges.value ->> 'start' as start,
455 bodyRanges.value ->> 'length' as length
456 FROM messages, json_each(messages.json ->> 'bodyRanges') as bodyRanges
457 WHERE bodyRanges.value ->> 'mentionAci' IS NOT NULL
459 AND messages.id = new.id;
464 <p>Finally I have the tool needed to inspect and process Signal
465 messages that I need, without using the vendor provided client. Now
466 on to transforming it to a more useful format.</p>
468 <p>As usual, if you use Bitcoin and want to show your support of my
469 activities, please send Bitcoin donations to my address
470 <b><a href="bitcoin:15oWEoG9dUPovwmUL9KWAnYRtNJEkP1u1b">15oWEoG9dUPovwmUL9KWAnYRtNJEkP1u1b</a></b>.</p>