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.
@@ -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
+ });