signal-db-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -0
- package/docs/MANUAL.md +187 -0
- package/lib/signal-db.js +432 -0
- package/package.json +47 -0
- package/signal-db-cli.js +625 -0
- package/signal-db-mcp.js +206 -0
package/signal-db-cli.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI entrypoint for browsing a local Signal Desktop database.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities in this file:
|
|
7
|
+
* - load env configuration from local and global locations
|
|
8
|
+
* - register commander commands and shared global flags
|
|
9
|
+
* - format terminal output for human-readable and JSON modes
|
|
10
|
+
*
|
|
11
|
+
* Database access and SQL queries live in `lib/signal-db.js`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import os from 'os';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
import { Command } from 'commander';
|
|
18
|
+
import dotenv from 'dotenv';
|
|
19
|
+
import pkg from './package.json' with { type: 'json' };
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
dotenv.config({ quiet: true }); // local .env first
|
|
24
|
+
dotenv.config({ path: path.join(os.homedir(), '.signal-db-cli', '.env'), quiet: true }); // fallback to global
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
openDB,
|
|
28
|
+
formatDate,
|
|
29
|
+
formatMessage,
|
|
30
|
+
formatCall,
|
|
31
|
+
getMessages,
|
|
32
|
+
findConversations,
|
|
33
|
+
getMessageById,
|
|
34
|
+
getConversations,
|
|
35
|
+
getCalls,
|
|
36
|
+
} from './lib/signal-db.js';
|
|
37
|
+
|
|
38
|
+
/** Exit early when a DB-backed command is invoked without the decryption key. */
|
|
39
|
+
function checkEnv() {
|
|
40
|
+
if (!process.env.SIGNAL_DECRYPTION_KEY) {
|
|
41
|
+
console.error('Missing SIGNAL_DECRYPTION_KEY in .env');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render a mixed message timeline in a consistent terminal format.
|
|
48
|
+
*
|
|
49
|
+
* Options allow callers to reuse the same renderer for:
|
|
50
|
+
* - global timelines with conversation labels
|
|
51
|
+
* - per-conversation views with direction arrows
|
|
52
|
+
* - unread views with a hint that a call happened afterwards
|
|
53
|
+
*/
|
|
54
|
+
function printMessages(messages, options = {}) {
|
|
55
|
+
const { showConv = true, showDir = false, showCallAfter = false } = options;
|
|
56
|
+
messages.forEach((msg, i) => {
|
|
57
|
+
const label = showConv ? `${msg.conversationName || msg.conversationPhone || msg.conversationId}` : '';
|
|
58
|
+
if (msg.type === 'call-history') {
|
|
59
|
+
const callStr = formatCall(msg);
|
|
60
|
+
console.log(`${i + 1}. [${formatDate(msg.sent_at)}] ${label ? label + ': ' : ''}${callStr}`);
|
|
61
|
+
} else {
|
|
62
|
+
const fmt = formatMessage(msg);
|
|
63
|
+
const prefix = showDir ? `${fmt.dir} ` : '';
|
|
64
|
+
const callHint = showCallAfter && msg.has_call_after ? ' 📞 call made' : '';
|
|
65
|
+
console.log(`${i + 1}. [${formatDate(msg.sent_at)}] ${label ? label + ': ' : ''}${prefix}${fmt.body}${callHint}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Emit JSON only when requested, while keeping the raw data available to callers. */
|
|
71
|
+
function output(data, options) {
|
|
72
|
+
if (options.json) {
|
|
73
|
+
console.log(JSON.stringify(data, null, 2));
|
|
74
|
+
}
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const program = new Command();
|
|
79
|
+
|
|
80
|
+
program
|
|
81
|
+
.name('signal-db-cli')
|
|
82
|
+
.description('CLI for browsing a local Signal Desktop database')
|
|
83
|
+
.version(pkg.version, '-V, --version', 'show version')
|
|
84
|
+
.option('-i, --interactive', 'interactive mode (Inquirer)')
|
|
85
|
+
.option('--json', 'output as JSON')
|
|
86
|
+
.option('-n, --limit <number>', 'limit results', parseInt)
|
|
87
|
+
.hook('preAction', async (_parentCommand, actionCommand) => {
|
|
88
|
+
// Keep update checks centralized so every command behaves the same way.
|
|
89
|
+
if (!process.env.NO_UPDATE_NOTIFIER) {
|
|
90
|
+
try {
|
|
91
|
+
const { default: updateNotifier } = await import('update-notifier');
|
|
92
|
+
const notifier = updateNotifier({
|
|
93
|
+
pkg,
|
|
94
|
+
updateCheckInterval: 1000 * 60 * 60 * 24,
|
|
95
|
+
});
|
|
96
|
+
notifier.notify();
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore update check errors
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Commands that don't need the decryption key skip the env check.
|
|
103
|
+
const cmdName = actionCommand.name();
|
|
104
|
+
if (cmdName !== 'decrypt' && cmdName !== 'manual') {
|
|
105
|
+
checkEnv();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Unified message query with composable filters.
|
|
110
|
+
program
|
|
111
|
+
.command('messages')
|
|
112
|
+
.alias('msg')
|
|
113
|
+
.description('Messages with filters (full-text, conversation, unread, unanswered, date)')
|
|
114
|
+
.argument('[query]', 'full-text search in message body')
|
|
115
|
+
.option('--conv <name>', 'conversation filter (name, =exact name, or UUID)')
|
|
116
|
+
.option('--unread', 'only unread incoming')
|
|
117
|
+
.option('--unanswered [hours]', 'unanswered, older than N hours (default 24)')
|
|
118
|
+
.option('--from <date>', 'from date (ISO e.g. 2025-01-15)')
|
|
119
|
+
.option('--to <date>', 'to date (ISO e.g. 2025-02-17)')
|
|
120
|
+
.option('--incoming', 'only incoming')
|
|
121
|
+
.option('--outgoing', 'only outgoing')
|
|
122
|
+
.action(async (query, options, cmd) => {
|
|
123
|
+
const opts = cmd.parent ? cmd.parent.opts() : {};
|
|
124
|
+
const limit = opts.limit ?? 20;
|
|
125
|
+
const interactive = opts.interactive;
|
|
126
|
+
const json = opts.json;
|
|
127
|
+
const db = openDB();
|
|
128
|
+
|
|
129
|
+
// Interactive conversation picker when --conv used with -i or without value
|
|
130
|
+
let convFilter = options.conv;
|
|
131
|
+
if (interactive && !convFilter) {
|
|
132
|
+
const { search } = await import('@inquirer/prompts');
|
|
133
|
+
const convs = getConversations(db, { limit: 100 });
|
|
134
|
+
const choices = convs.map((c) => ({
|
|
135
|
+
value: c.id,
|
|
136
|
+
name: c.name || c.e164 || c.id,
|
|
137
|
+
description: c.type,
|
|
138
|
+
}));
|
|
139
|
+
convFilter = await search({
|
|
140
|
+
message: 'Select conversation',
|
|
141
|
+
source: async (input) => {
|
|
142
|
+
if (!input) return choices.slice(0, 20);
|
|
143
|
+
const q = input.toLowerCase();
|
|
144
|
+
return choices.filter((c) => (c.name || '').toLowerCase().includes(q)).slice(0, 20);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Interactive FTS search when -i and no query
|
|
150
|
+
if (interactive && !query && !options.unread && !options.unanswered && !convFilter) {
|
|
151
|
+
const { search, Separator } = await import('@inquirer/prompts');
|
|
152
|
+
const msgId = await search({
|
|
153
|
+
message: 'Search messages (type – results appear live)',
|
|
154
|
+
source: async (input) => {
|
|
155
|
+
if (!input || input.trim().length < 2) return [];
|
|
156
|
+
try {
|
|
157
|
+
const result = getMessages(db, { search: input.trim(), from: options.from, to: options.to, limit });
|
|
158
|
+
if (result.messages.length === 0) return [];
|
|
159
|
+
return [
|
|
160
|
+
new Separator(`Found ${result.total} messages (showing ${result.messages.length})`),
|
|
161
|
+
...result.messages.map((m) => ({
|
|
162
|
+
value: m.id,
|
|
163
|
+
name: `${formatDate(m.sent_at)} ${(m.conversationName || m.conversationId)}: ${(m.body || '').slice(0, 50)}...`,
|
|
164
|
+
description: (m.body || '').slice(0, 100),
|
|
165
|
+
})),
|
|
166
|
+
];
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
if (msgId) {
|
|
173
|
+
const msg = getMessageById(db, msgId);
|
|
174
|
+
if (msg) {
|
|
175
|
+
console.log(`\n--- Message ---`);
|
|
176
|
+
console.log(`Conversation: ${msg.conversationName || msg.conversationId}`);
|
|
177
|
+
console.log(`Date: ${formatDate(msg.sent_at)}`);
|
|
178
|
+
console.log(`\n${msg.body}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const unansweredHours = options.unanswered === true ? 24 : parseInt(options.unanswered, 10) || undefined;
|
|
185
|
+
|
|
186
|
+
const result = getMessages(db, {
|
|
187
|
+
conv: convFilter,
|
|
188
|
+
unread: options.unread || false,
|
|
189
|
+
unanswered: !!options.unanswered,
|
|
190
|
+
olderThan: unansweredHours,
|
|
191
|
+
search: query,
|
|
192
|
+
from: options.from,
|
|
193
|
+
to: options.to,
|
|
194
|
+
incoming: options.incoming || false,
|
|
195
|
+
outgoing: options.outgoing || false,
|
|
196
|
+
limit,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (json) {
|
|
200
|
+
output(result, { json: true });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build header
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (options.unread) parts.push('unread');
|
|
207
|
+
if (options.unanswered) parts.push(`unanswered (>${unansweredHours || 24}h)`);
|
|
208
|
+
if (convFilter) parts.push(result.conversationName || convFilter);
|
|
209
|
+
if (query) parts.push(`"${query}"`);
|
|
210
|
+
if (options.incoming) parts.push('incoming');
|
|
211
|
+
if (options.outgoing) parts.push('outgoing');
|
|
212
|
+
const header = parts.length > 0 ? parts.join(' | ') : 'recent messages';
|
|
213
|
+
console.log(`\n--- ${header} (${result.messages.length}/${result.total}) ---\n`);
|
|
214
|
+
|
|
215
|
+
if (result.messages.length === 0) return;
|
|
216
|
+
|
|
217
|
+
// Render options based on active filters
|
|
218
|
+
const showConv = !convFilter;
|
|
219
|
+
const showDir = !!convFilter;
|
|
220
|
+
const showCallAfter = !!options.unread;
|
|
221
|
+
|
|
222
|
+
if (options.unanswered) {
|
|
223
|
+
result.messages.forEach((msg, i) => {
|
|
224
|
+
const fmt = formatMessage(msg);
|
|
225
|
+
const age = Math.round((Date.now() - msg.sent_at) / (1000 * 60 * 60));
|
|
226
|
+
const label = msg.conversationName || msg.conversationPhone || msg.conversationId;
|
|
227
|
+
const count = msg.rottingCount > 1 ? ` (${msg.rottingCount} messages)` : '';
|
|
228
|
+
console.log(`${i + 1}. [${formatDate(msg.sent_at)}] (${age}h) ${label}${count}: ${fmt.body}`);
|
|
229
|
+
});
|
|
230
|
+
} else {
|
|
231
|
+
printMessages(result.messages, { showConv, showDir, showCallAfter });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Conversation inventory with optional search/filtering.
|
|
236
|
+
program
|
|
237
|
+
.command('convs')
|
|
238
|
+
.description('List conversations')
|
|
239
|
+
.argument('[query]', 'search conversations by name')
|
|
240
|
+
.option('-t, --type <type>', 'filter: private | group')
|
|
241
|
+
.action(async (query, options, cmd) => {
|
|
242
|
+
const opts = cmd.parent ? cmd.parent.opts() : {};
|
|
243
|
+
const limit = opts.limit ?? 50;
|
|
244
|
+
const json = opts.json;
|
|
245
|
+
const db = openDB();
|
|
246
|
+
|
|
247
|
+
if (query) {
|
|
248
|
+
const convs = findConversations(db, query);
|
|
249
|
+
if (json) {
|
|
250
|
+
output({ conversations: convs }, { json: true });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (convs.length === 0) {
|
|
254
|
+
console.log(`No conversation matches "${query}"`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
console.log(`\n--- Conversations matching "${query}" ---\n`);
|
|
258
|
+
convs.forEach((c) => {
|
|
259
|
+
console.log(` ${c.name || c.e164 || '(unnamed)'} [${c.id}]`);
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const convs = getConversations(db, { type: options.type || null, limit });
|
|
265
|
+
if (json) {
|
|
266
|
+
output({ conversations: convs }, { json: true });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
console.log(`\n--- Conversations (${convs.length}) ---\n`);
|
|
270
|
+
convs.forEach((c, i) => {
|
|
271
|
+
console.log(`${i + 1}. ${c.name || c.e164 || '(unnamed)'} [${c.type}] ${formatDate(c.active_at)}`);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Phone number lookup by contact name.
|
|
276
|
+
program
|
|
277
|
+
.command('phone')
|
|
278
|
+
.description('Look up phone number by name')
|
|
279
|
+
.argument('<query>', 'contact name')
|
|
280
|
+
.action(async (query, _options, cmd) => {
|
|
281
|
+
const opts = cmd.parent ? cmd.parent.opts() : {};
|
|
282
|
+
const json = opts.json;
|
|
283
|
+
const db = openDB();
|
|
284
|
+
const convs = findConversations(db, query).filter((c) => c.e164);
|
|
285
|
+
if (json) {
|
|
286
|
+
output({ contacts: convs.map((c) => ({ name: c.name, phone: c.e164 })) }, { json: true });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (convs.length === 0) {
|
|
290
|
+
console.log(`No contact with phone number matches "${query}"`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
console.log(`\n--- Contacts for "${query}" ---\n`);
|
|
294
|
+
convs.forEach((c) => {
|
|
295
|
+
console.log(` ${c.name || '(unnamed)'} ${c.e164}`);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Call history shown independently from message timelines.
|
|
300
|
+
program
|
|
301
|
+
.command('calls')
|
|
302
|
+
.description('Call history')
|
|
303
|
+
.argument('[n]', 'number of calls', (v) => parseInt(v, 10) || 20)
|
|
304
|
+
.action(async (n, options, cmd) => {
|
|
305
|
+
const opts = cmd.parent ? cmd.parent.opts() : {};
|
|
306
|
+
const limit = opts.limit ?? n ?? 20;
|
|
307
|
+
const json = opts.json;
|
|
308
|
+
const db = openDB();
|
|
309
|
+
const calls = getCalls(db, limit);
|
|
310
|
+
if (json) {
|
|
311
|
+
output({ calls }, { json: true });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
console.log(`\n--- Last ${calls.length} calls ---\n`);
|
|
315
|
+
calls.forEach((c, i) => {
|
|
316
|
+
const dir = (c.direction || '').toLowerCase() === 'incoming' ? '↓' : '↑';
|
|
317
|
+
console.log(`${i + 1}. [${formatDate(c.timestamp)}] 📞${dir} ${c.conversationName || c.conversationPhone || '?'} ${c.status} ${c.mode || ''}`);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Shortcut menu for the most common interactive workflows.
|
|
322
|
+
program
|
|
323
|
+
.command('interactive')
|
|
324
|
+
.alias('i')
|
|
325
|
+
.description('Interactive mode – main menu')
|
|
326
|
+
.action(async () => {
|
|
327
|
+
const { select, search, Separator } = await import('@inquirer/prompts');
|
|
328
|
+
const db = openDB();
|
|
329
|
+
const choice = await select({
|
|
330
|
+
message: 'What do you want to do?',
|
|
331
|
+
choices: [
|
|
332
|
+
{ value: 'unread', name: 'Unread messages' },
|
|
333
|
+
{ value: 'last', name: 'Recent messages' },
|
|
334
|
+
{ value: 'conv', name: 'Conversations – messages from a selected conversation' },
|
|
335
|
+
{ value: 'search', name: 'Search messages' },
|
|
336
|
+
{ value: 'unanswered', name: 'Unanswered' },
|
|
337
|
+
{ value: 'calls', name: 'Call history' },
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
if (choice === 'unread') {
|
|
341
|
+
const result = getMessages(db, { unread: true, limit: 50 });
|
|
342
|
+
console.log('\n--- Unread incoming messages ---');
|
|
343
|
+
console.log(`Total: ${result.total} (showing ${result.messages.length})\n`);
|
|
344
|
+
printMessages(result.messages, { showCallAfter: true });
|
|
345
|
+
} else if (choice === 'last') {
|
|
346
|
+
const result = getMessages(db, { limit: 20 });
|
|
347
|
+
console.log(`\n--- Last ${result.messages.length} messages ---\n`);
|
|
348
|
+
printMessages(result.messages);
|
|
349
|
+
} else if (choice === 'conv') {
|
|
350
|
+
const convs = getConversations(db, { limit: 100 });
|
|
351
|
+
const choices = convs.map((c) => ({
|
|
352
|
+
value: c.id,
|
|
353
|
+
name: c.name || c.e164 || c.id,
|
|
354
|
+
}));
|
|
355
|
+
const convId = await search({
|
|
356
|
+
message: 'Select conversation (type to filter)',
|
|
357
|
+
source: async (input) => {
|
|
358
|
+
if (!input) return choices.slice(0, 25);
|
|
359
|
+
const q = input.toLowerCase();
|
|
360
|
+
return choices.filter((c) => (c.name || '').toLowerCase().includes(q)).slice(0, 25);
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const result = getMessages(db, { conv: convId, limit: 15 });
|
|
364
|
+
console.log(`\n--- ${result.conversationName || convId} ---\n`);
|
|
365
|
+
printMessages(result.messages, { showConv: false, showDir: true });
|
|
366
|
+
} else if (choice === 'search') {
|
|
367
|
+
const msgId = await search({
|
|
368
|
+
message: 'Search messages (type – results appear live)',
|
|
369
|
+
source: async (input) => {
|
|
370
|
+
if (!input || input.trim().length < 2) return [];
|
|
371
|
+
try {
|
|
372
|
+
const result = getMessages(db, { search: input.trim(), limit: 15 });
|
|
373
|
+
if (result.messages.length === 0) return [];
|
|
374
|
+
return [
|
|
375
|
+
new Separator(`Found ${result.total} messages (showing ${result.messages.length})`),
|
|
376
|
+
...result.messages.map((m) => ({
|
|
377
|
+
value: m.id,
|
|
378
|
+
name: `${formatDate(m.sent_at)} ${(m.conversationName || m.conversationId)}: ${(m.body || '').slice(0, 50)}...`,
|
|
379
|
+
description: (m.body || '').slice(0, 100),
|
|
380
|
+
})),
|
|
381
|
+
];
|
|
382
|
+
} catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
if (msgId) {
|
|
388
|
+
const msg = getMessageById(db, msgId);
|
|
389
|
+
if (msg) {
|
|
390
|
+
console.log(`\n--- Message ---`);
|
|
391
|
+
console.log(`Conversation: ${msg.conversationName || msg.conversationId}`);
|
|
392
|
+
console.log(`Date: ${formatDate(msg.sent_at)}`);
|
|
393
|
+
console.log(`\n${msg.body}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} else if (choice === 'unanswered') {
|
|
397
|
+
const result = getMessages(db, { unanswered: true, olderThan: 24, limit: 30 });
|
|
398
|
+
if (result.messages.length === 0) {
|
|
399
|
+
console.log('\nNo unanswered messages.');
|
|
400
|
+
} else {
|
|
401
|
+
console.log('\n--- Unanswered ---\n');
|
|
402
|
+
result.messages.forEach((msg, idx) => {
|
|
403
|
+
const fmt = formatMessage(msg);
|
|
404
|
+
const age = Math.round((Date.now() - msg.sent_at) / (1000 * 60 * 60));
|
|
405
|
+
const label = msg.conversationName || msg.conversationPhone || msg.conversationId;
|
|
406
|
+
const count = msg.rottingCount > 1 ? ` (${msg.rottingCount} messages)` : '';
|
|
407
|
+
console.log(`${idx + 1}. [${formatDate(msg.sent_at)}] (${age}h) ${label}${count}: ${fmt.body}`);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
} else if (choice === 'calls') {
|
|
411
|
+
const calls = getCalls(db, 20);
|
|
412
|
+
console.log('\n--- Last 20 calls ---\n');
|
|
413
|
+
calls.forEach((c, idx) => {
|
|
414
|
+
const dir = (c.direction || '').toLowerCase() === 'incoming' ? '↓' : '↑';
|
|
415
|
+
console.log(`${idx + 1}. [${formatDate(c.timestamp)}] 📞${dir} ${c.conversationName || '?'} ${c.status}`);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Print the bundled manual directly from the repository.
|
|
421
|
+
program
|
|
422
|
+
.command('manual')
|
|
423
|
+
.description('Extended documentation')
|
|
424
|
+
.action(async () => {
|
|
425
|
+
const fs = await import('fs');
|
|
426
|
+
const manualPath = path.join(__dirname, 'docs', 'MANUAL.md');
|
|
427
|
+
if (fs.existsSync(manualPath)) {
|
|
428
|
+
console.log(fs.readFileSync(manualPath, 'utf8'));
|
|
429
|
+
} else {
|
|
430
|
+
console.log('File docs/MANUAL.md not found.');
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// --- Decrypt helpers (platform-specific) ---
|
|
435
|
+
|
|
436
|
+
/** Retrieve Signal password from Linux keyring (GNOME Keyring or KWallet). */
|
|
437
|
+
function getLinuxKeyringPassword(execSync) {
|
|
438
|
+
for (const appName of ['signal', 'Signal']) {
|
|
439
|
+
try {
|
|
440
|
+
const pw = execSync(`secret-tool lookup application ${appName}`, {
|
|
441
|
+
encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
442
|
+
}).trim();
|
|
443
|
+
if (pw) return pw;
|
|
444
|
+
} catch { /* try next */ }
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const pw = execSync(
|
|
448
|
+
'kwallet-query -r "Signal Safe Storage" kdewallet',
|
|
449
|
+
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
450
|
+
).trim();
|
|
451
|
+
if (pw) return pw;
|
|
452
|
+
} catch { /* not KDE */ }
|
|
453
|
+
console.error(
|
|
454
|
+
'Cannot retrieve Signal password from keyring.\n\n' +
|
|
455
|
+
'For GNOME Keyring, install libsecret-tools:\n' +
|
|
456
|
+
' sudo apt install libsecret-tools\n' +
|
|
457
|
+
' secret-tool lookup application signal\n\n' +
|
|
458
|
+
'For KDE KWallet:\n' +
|
|
459
|
+
' kwallet-query -r "Signal Safe Storage" kdewallet',
|
|
460
|
+
);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Decrypt a DPAPI-protected buffer via PowerShell (Windows). */
|
|
465
|
+
function dpapiDecrypt(execSync, buf) {
|
|
466
|
+
// Base64 charset [A-Za-z0-9+/=] is safe inside a PowerShell single-quoted string
|
|
467
|
+
const b64 = buf.toString('base64');
|
|
468
|
+
const psCommand =
|
|
469
|
+
'Add-Type -AssemblyName System.Security; ' +
|
|
470
|
+
'[Convert]::ToBase64String(' +
|
|
471
|
+
'[System.Security.Cryptography.ProtectedData]::Unprotect(' +
|
|
472
|
+
`[Convert]::FromBase64String('${b64}'),` +
|
|
473
|
+
'$null,' +
|
|
474
|
+
'[System.Security.Cryptography.DataProtectionScope]::CurrentUser))';
|
|
475
|
+
const result = execSync(
|
|
476
|
+
`powershell -NoProfile -NonInteractive -Command "${psCommand}"`,
|
|
477
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
478
|
+
).trim();
|
|
479
|
+
return Buffer.from(result, 'base64');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Windows AES-256-GCM decryption with DPAPI-protected master key (Chromium os_crypt v10/v11). */
|
|
483
|
+
function decryptWindowsAesGcm(crypto, execSync, fs, encBuf, signalDir) {
|
|
484
|
+
const localStatePath = path.join(signalDir, 'Local State');
|
|
485
|
+
if (!fs.existsSync(localStatePath)) {
|
|
486
|
+
console.error(`Local State not found: ${localStatePath}`);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const localState = JSON.parse(fs.readFileSync(localStatePath, 'utf8'));
|
|
490
|
+
const masterKeyB64 = localState?.os_crypt?.encrypted_key;
|
|
491
|
+
if (!masterKeyB64) {
|
|
492
|
+
console.error('No os_crypt.encrypted_key in Local State');
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
const masterKeyRaw = Buffer.from(masterKeyB64, 'base64');
|
|
496
|
+
if (masterKeyRaw.subarray(0, 5).toString('ascii') !== 'DPAPI') {
|
|
497
|
+
console.error('Unexpected master key prefix (expected "DPAPI")');
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
const masterKey = dpapiDecrypt(execSync, masterKeyRaw.subarray(5));
|
|
501
|
+
if (masterKey.length !== 32) {
|
|
502
|
+
console.error(`Master key is ${masterKey.length} bytes, expected 32`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
// encBuf layout: [3B prefix][12B nonce][ciphertext][16B GCM tag]
|
|
506
|
+
const nonce = encBuf.subarray(3, 15);
|
|
507
|
+
const ciphertextAndTag = encBuf.subarray(15);
|
|
508
|
+
const authTag = ciphertextAndTag.subarray(-16);
|
|
509
|
+
const ciphertext = ciphertextAndTag.subarray(0, -16);
|
|
510
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', masterKey, nonce);
|
|
511
|
+
decipher.setAuthTag(authTag);
|
|
512
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Extract the SQLCipher decryption key and save it to ~/.signal-db-cli/.env.
|
|
516
|
+
program
|
|
517
|
+
.command('decrypt')
|
|
518
|
+
.description('Extract decryption key from Signal Desktop and save to ~/.signal-db-cli/.env')
|
|
519
|
+
.action(async () => {
|
|
520
|
+
const crypto = await import('crypto');
|
|
521
|
+
const { execSync } = await import('child_process');
|
|
522
|
+
const fs = await import('fs');
|
|
523
|
+
const plat = process.platform;
|
|
524
|
+
|
|
525
|
+
// 1. Find Signal data directory
|
|
526
|
+
const signalDir = process.env.SIGNAL_DIR || (() => {
|
|
527
|
+
if (plat === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Signal');
|
|
528
|
+
if (plat === 'linux') return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'Signal');
|
|
529
|
+
if (plat === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Signal');
|
|
530
|
+
console.error(`Unsupported platform: ${plat}`);
|
|
531
|
+
process.exit(1);
|
|
532
|
+
})();
|
|
533
|
+
|
|
534
|
+
// 2. Read config.json
|
|
535
|
+
const configPath = path.join(signalDir, 'config.json');
|
|
536
|
+
if (!fs.existsSync(configPath)) {
|
|
537
|
+
console.error(`Signal config not found: ${configPath}`);
|
|
538
|
+
if (plat === 'linux') {
|
|
539
|
+
console.error(
|
|
540
|
+
'Standard locations:\n' +
|
|
541
|
+
' ~/.config/Signal/config.json\n' +
|
|
542
|
+
' ~/.var/app/org.signal.Signal/config/Signal/config.json (Flatpak)',
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
548
|
+
|
|
549
|
+
// 3. Extract key
|
|
550
|
+
let key;
|
|
551
|
+
if (config.key) {
|
|
552
|
+
// Plaintext key (legacy or already decrypted)
|
|
553
|
+
key = config.key;
|
|
554
|
+
} else if (!config.encryptedKey) {
|
|
555
|
+
console.error('No "encryptedKey" or "key" found in config.json');
|
|
556
|
+
process.exit(1);
|
|
557
|
+
} else {
|
|
558
|
+
const encBuf = Buffer.from(config.encryptedKey, 'hex');
|
|
559
|
+
const prefix = encBuf.subarray(0, 3).toString('ascii');
|
|
560
|
+
|
|
561
|
+
if (plat === 'darwin') {
|
|
562
|
+
// macOS: AES-128-CBC with Keychain password, PBKDF2 1003 iterations
|
|
563
|
+
if (prefix !== 'v10') {
|
|
564
|
+
console.error(`Unexpected prefix: "${prefix}" (expected "v10")`);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
const keychainPassword = execSync(
|
|
568
|
+
'security find-generic-password -s "Signal Safe Storage" -w',
|
|
569
|
+
{ encoding: 'utf8' },
|
|
570
|
+
).trim();
|
|
571
|
+
const derivedKey = crypto.pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1');
|
|
572
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
573
|
+
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
|
|
574
|
+
key = Buffer.concat([decipher.update(encBuf.subarray(3)), decipher.final()]).toString('utf8');
|
|
575
|
+
|
|
576
|
+
} else if (plat === 'linux') {
|
|
577
|
+
// Linux: v10 = "peanuts" password, v11 = keyring password; PBKDF2 1 iteration
|
|
578
|
+
let password;
|
|
579
|
+
if (prefix === 'v10') {
|
|
580
|
+
password = 'peanuts';
|
|
581
|
+
} else if (prefix === 'v11') {
|
|
582
|
+
password = getLinuxKeyringPassword(execSync);
|
|
583
|
+
} else {
|
|
584
|
+
console.error(`Unknown prefix: "${prefix}" (expected "v10" or "v11")`);
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
const derivedKey = crypto.pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
|
|
588
|
+
const iv = Buffer.alloc(16, 0x20);
|
|
589
|
+
const decipher = crypto.createDecipheriv('aes-128-cbc', derivedKey, iv);
|
|
590
|
+
key = Buffer.concat([decipher.update(encBuf.subarray(3)), decipher.final()]).toString('utf8');
|
|
591
|
+
|
|
592
|
+
} else if (plat === 'win32') {
|
|
593
|
+
// Windows: v10/v11 = AES-256-GCM with DPAPI master key, older = DPAPI directly
|
|
594
|
+
if (prefix === 'v10' || prefix === 'v11') {
|
|
595
|
+
key = decryptWindowsAesGcm(crypto, execSync, fs, encBuf, signalDir);
|
|
596
|
+
} else {
|
|
597
|
+
key = dpapiDecrypt(execSync, encBuf).toString('utf8');
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// 4. Save to ~/.signal-db-cli/.env
|
|
603
|
+
const envDir = path.join(os.homedir(), '.signal-db-cli');
|
|
604
|
+
const envPath = path.join(envDir, '.env');
|
|
605
|
+
fs.mkdirSync(envDir, { recursive: true });
|
|
606
|
+
|
|
607
|
+
let content = '';
|
|
608
|
+
if (fs.existsSync(envPath)) {
|
|
609
|
+
content = fs.readFileSync(envPath, 'utf8');
|
|
610
|
+
if (/^SIGNAL_DECRYPTION_KEY=.*/m.test(content)) {
|
|
611
|
+
content = content.replace(/^SIGNAL_DECRYPTION_KEY=.*/m, `SIGNAL_DECRYPTION_KEY=${key}`);
|
|
612
|
+
} else {
|
|
613
|
+
content = content.trimEnd() + `\nSIGNAL_DECRYPTION_KEY=${key}\n`;
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
content = `SIGNAL_DECRYPTION_KEY=${key}\n`;
|
|
617
|
+
}
|
|
618
|
+
fs.writeFileSync(envPath, content);
|
|
619
|
+
console.log(`Decryption key saved to ${envPath}`);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
program.parseAsync().catch((err) => {
|
|
623
|
+
console.error(err.message || err);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
});
|