browser-commander 0.15.0 → 0.16.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,628 @@
1
+ import assert from 'node:assert';
2
+ import { execFile as execFileCallback } from 'node:child_process';
3
+ import {
4
+ createCipheriv,
5
+ createHash,
6
+ pbkdf2Sync,
7
+ randomBytes,
8
+ } from 'node:crypto';
9
+ import {
10
+ chmod,
11
+ mkdir,
12
+ mkdtemp,
13
+ readFile,
14
+ readdir,
15
+ rm,
16
+ stat,
17
+ writeFile,
18
+ } from 'node:fs/promises';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { promisify } from 'node:util';
23
+ import { afterEach, describe, it } from 'node:test';
24
+
25
+ import {
26
+ clearBrowserCookieMemoryCache,
27
+ decryptChromiumCookie,
28
+ listBrowserProfiles,
29
+ readBrowserCookies,
30
+ readBrowserCookiesWithDependencies,
31
+ } from '../../../src/browser/browser-cookies.js';
32
+ import { openSqliteDatabase } from '../../../src/browser/browser-cookie-database.js';
33
+ import { getCachedCredential } from '../../../src/browser/browser-cookie-cache.js';
34
+ import { readSafeStoragePassword } from '../../../src/browser/browser-cookie-credentials.js';
35
+ import { decodeChromiumCookiePlaintext } from '../../../src/browser/browser-cookie-crypto.js';
36
+ import {
37
+ listBrowserProfiles as publicListBrowserProfiles,
38
+ readBrowserCookies as publicReadBrowserCookies,
39
+ } from '../../../src/index.js';
40
+
41
+ const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600;
42
+ const execFile = promisify(execFileCallback);
43
+ const credentialWorker = fileURLToPath(
44
+ new URL('../../fixtures/browser-cookie-cache-worker.mjs', import.meta.url)
45
+ );
46
+
47
+ function chromeExpires(unixSeconds) {
48
+ return (unixSeconds + CHROME_EPOCH_OFFSET_SECONDS) * 1_000_000;
49
+ }
50
+
51
+ function encryptCbcCookie({ host, value, password, prefix = 'v11' }) {
52
+ const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1');
53
+ const plaintext = Buffer.concat([
54
+ createHash('sha256').update(host).digest(),
55
+ Buffer.from(value),
56
+ ]);
57
+ const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20));
58
+ return Buffer.concat([
59
+ Buffer.from(prefix),
60
+ cipher.update(plaintext),
61
+ cipher.final(),
62
+ ]);
63
+ }
64
+
65
+ function encryptGcmCookie({ host, value, key, prefix = 'v10' }) {
66
+ const nonce = randomBytes(12);
67
+ const plaintext = Buffer.concat([
68
+ createHash('sha256').update(host).digest(),
69
+ Buffer.from(value),
70
+ ]);
71
+ const cipher = createCipheriv('aes-256-gcm', key, nonce);
72
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
73
+ return Buffer.concat([
74
+ Buffer.from(prefix),
75
+ nonce,
76
+ ciphertext,
77
+ cipher.getAuthTag(),
78
+ ]);
79
+ }
80
+
81
+ async function createChromiumProfile({ homeDir, rows, profile = 'Default' }) {
82
+ const root = path.join(homeDir, '.config', 'google-chrome');
83
+ const profilePath = path.join(root, profile);
84
+ const cookiePath = path.join(profilePath, 'Network', 'Cookies');
85
+ await mkdir(path.dirname(cookiePath), { recursive: true });
86
+ await writeFile(
87
+ path.join(root, 'Local State'),
88
+ JSON.stringify({
89
+ profile: {
90
+ last_used: profile,
91
+ info_cache: { [profile]: { name: 'Primary profile' } },
92
+ },
93
+ })
94
+ );
95
+
96
+ const database = await openSqliteDatabase(cookiePath);
97
+ database.exec(`
98
+ CREATE TABLE meta (key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
99
+ INSERT INTO meta (key, value) VALUES ('version', '24');
100
+ CREATE TABLE cookies (
101
+ host_key TEXT NOT NULL,
102
+ name TEXT NOT NULL,
103
+ value TEXT NOT NULL DEFAULT '',
104
+ encrypted_value BLOB NOT NULL DEFAULT '',
105
+ path TEXT NOT NULL,
106
+ expires_utc INTEGER NOT NULL,
107
+ is_secure INTEGER NOT NULL,
108
+ is_httponly INTEGER NOT NULL,
109
+ samesite INTEGER NOT NULL
110
+ );
111
+ `);
112
+ const insert = database.prepare(`
113
+ INSERT INTO cookies (
114
+ host_key, name, value, encrypted_value, path, expires_utc,
115
+ is_secure, is_httponly, samesite
116
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
117
+ `);
118
+ for (const row of rows) {
119
+ insert.run(
120
+ row.host,
121
+ row.name,
122
+ row.value ?? '',
123
+ row.encryptedValue ?? Buffer.alloc(0),
124
+ row.path ?? '/',
125
+ row.expiresUtc ?? 0,
126
+ row.secure ? 1 : 0,
127
+ row.httpOnly ? 1 : 0,
128
+ row.sameSite ?? -1
129
+ );
130
+ }
131
+ database.close();
132
+ return { cookiePath, profilePath, root };
133
+ }
134
+
135
+ async function createFirefoxProfile({ homeDir, rows }) {
136
+ const root = path.join(homeDir, '.mozilla', 'firefox');
137
+ const profileName = 'fixture.default-release';
138
+ const profilePath = path.join(root, profileName);
139
+ const cookiePath = path.join(profilePath, 'cookies.sqlite');
140
+ await mkdir(profilePath, { recursive: true });
141
+ await writeFile(
142
+ path.join(root, 'profiles.ini'),
143
+ `[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n`
144
+ );
145
+ const database = await openSqliteDatabase(cookiePath);
146
+ database.exec(`
147
+ CREATE TABLE moz_cookies (
148
+ name TEXT,
149
+ value TEXT,
150
+ host TEXT,
151
+ path TEXT,
152
+ expiry INTEGER,
153
+ isSecure INTEGER,
154
+ isHttpOnly INTEGER,
155
+ sameSite INTEGER
156
+ );
157
+ `);
158
+ const insert = database.prepare(`
159
+ INSERT INTO moz_cookies
160
+ (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
162
+ `);
163
+ for (const row of rows) {
164
+ insert.run(
165
+ row.name,
166
+ row.value,
167
+ row.host,
168
+ row.path ?? '/',
169
+ row.expiry ?? 0,
170
+ row.secure ? 1 : 0,
171
+ row.httpOnly ? 1 : 0,
172
+ row.sameSite ?? 0
173
+ );
174
+ }
175
+ database.close();
176
+ return { cookiePath, profilePath, root };
177
+ }
178
+
179
+ describe('installed browser cookie import', () => {
180
+ let temporaryDirectory;
181
+
182
+ afterEach(async () => {
183
+ clearBrowserCookieMemoryCache();
184
+ if (temporaryDirectory) {
185
+ await chmod(temporaryDirectory, 0o700);
186
+ await rm(temporaryDirectory, { recursive: true, force: true });
187
+ temporaryDirectory = undefined;
188
+ }
189
+ });
190
+
191
+ it('exports both public helpers', () => {
192
+ assert.equal(publicListBrowserProfiles, listBrowserProfiles);
193
+ assert.equal(publicReadBrowserCookies, readBrowserCookies);
194
+ });
195
+
196
+ it('discovers named Chromium and Firefox profiles', async () => {
197
+ temporaryDirectory = await mkdtemp(
198
+ path.join(os.tmpdir(), 'browser-commander-cookie-profiles-')
199
+ );
200
+ const chromium = await createChromiumProfile({
201
+ homeDir: temporaryDirectory,
202
+ rows: [],
203
+ });
204
+ const firefox = await createFirefoxProfile({
205
+ homeDir: temporaryDirectory,
206
+ rows: [],
207
+ });
208
+
209
+ const profiles = await listBrowserProfiles({
210
+ platform: 'linux',
211
+ homeDir: temporaryDirectory,
212
+ environment: {},
213
+ });
214
+
215
+ assert.deepEqual(profiles, [
216
+ {
217
+ browser: 'chrome',
218
+ name: 'Default',
219
+ displayName: 'Primary profile',
220
+ path: chromium.profilePath,
221
+ isDefault: true,
222
+ },
223
+ {
224
+ browser: 'firefox',
225
+ name: 'default-release',
226
+ displayName: 'default-release',
227
+ path: firefox.profilePath,
228
+ isDefault: true,
229
+ },
230
+ ]);
231
+ });
232
+
233
+ it('decrypts Chromium CBC cookies and returns the engine cookie shape', async () => {
234
+ temporaryDirectory = await mkdtemp(
235
+ path.join(os.tmpdir(), 'browser-commander-cookie-read-')
236
+ );
237
+ const password = 'fixture safe storage password';
238
+ const host = '.example.com';
239
+ await createChromiumProfile({
240
+ homeDir: temporaryDirectory,
241
+ rows: [
242
+ {
243
+ host,
244
+ name: 'SID',
245
+ encryptedValue: encryptCbcCookie({
246
+ host,
247
+ value: 'decrypted-session',
248
+ password,
249
+ }),
250
+ expiresUtc: chromeExpires(2_000_000_000),
251
+ secure: true,
252
+ httpOnly: true,
253
+ sameSite: 2,
254
+ },
255
+ {
256
+ host: '.other.test',
257
+ name: 'ignored',
258
+ value: 'plain',
259
+ },
260
+ ],
261
+ });
262
+
263
+ let credentialReads = 0;
264
+ const cookies = await readBrowserCookiesWithDependencies(
265
+ {
266
+ browser: 'chrome',
267
+ domainFilter: 'example.com',
268
+ cache: {
269
+ dir: path.join(temporaryDirectory, 'cache'),
270
+ ttlMinutes: 60,
271
+ },
272
+ },
273
+ {
274
+ platform: 'linux',
275
+ homeDir: temporaryDirectory,
276
+ environment: {},
277
+ readSafeStoragePassword: async () => {
278
+ credentialReads += 1;
279
+ return password;
280
+ },
281
+ }
282
+ );
283
+
284
+ assert.deepEqual(cookies, [
285
+ {
286
+ name: 'SID',
287
+ value: 'decrypted-session',
288
+ domain: host,
289
+ path: '/',
290
+ expires: 2_000_000_000,
291
+ httpOnly: true,
292
+ secure: true,
293
+ sameSite: 'Strict',
294
+ },
295
+ ]);
296
+ assert.equal(credentialReads, 1);
297
+ });
298
+
299
+ it('reads Firefox cookies without touching the credential provider', async () => {
300
+ temporaryDirectory = await mkdtemp(
301
+ path.join(os.tmpdir(), 'browser-commander-firefox-cookie-')
302
+ );
303
+ await createFirefoxProfile({
304
+ homeDir: temporaryDirectory,
305
+ rows: [
306
+ {
307
+ host: '.example.org',
308
+ name: 'firefox-session',
309
+ value: 'plain-value',
310
+ expiry: 2_000_000_001,
311
+ secure: true,
312
+ sameSite: 1,
313
+ },
314
+ ],
315
+ });
316
+
317
+ const cookies = await readBrowserCookiesWithDependencies(
318
+ { browser: 'firefox' },
319
+ {
320
+ platform: 'linux',
321
+ homeDir: temporaryDirectory,
322
+ environment: {},
323
+ readSafeStoragePassword: async () => {
324
+ throw new Error('Firefox must not read an OS credential');
325
+ },
326
+ }
327
+ );
328
+
329
+ assert.deepEqual(cookies, [
330
+ {
331
+ name: 'firefox-session',
332
+ value: 'plain-value',
333
+ domain: '.example.org',
334
+ path: '/',
335
+ expires: 2_000_000_001,
336
+ httpOnly: false,
337
+ secure: true,
338
+ sameSite: 'Lax',
339
+ },
340
+ ]);
341
+ });
342
+
343
+ it('reuses the owner-only credential cache across memory-cache resets', async () => {
344
+ temporaryDirectory = await mkdtemp(
345
+ path.join(os.tmpdir(), 'browser-commander-cookie-cache-')
346
+ );
347
+ const password = 'cached safe storage password';
348
+ const rows = ['.example.com', '.example.org'].map((host, index) => ({
349
+ host,
350
+ name: `session-${index}`,
351
+ encryptedValue: encryptCbcCookie({
352
+ host,
353
+ value: `value-${index}`,
354
+ password,
355
+ }),
356
+ }));
357
+ await createChromiumProfile({ homeDir: temporaryDirectory, rows });
358
+ const cacheDir = path.join(temporaryDirectory, 'cache');
359
+ let credentialReads = 0;
360
+ const dependencies = {
361
+ platform: 'linux',
362
+ homeDir: temporaryDirectory,
363
+ environment: {},
364
+ readSafeStoragePassword: async () => {
365
+ credentialReads += 1;
366
+ return password;
367
+ },
368
+ };
369
+
370
+ await readBrowserCookiesWithDependencies(
371
+ {
372
+ browser: 'chrome',
373
+ domainFilter: 'example.com',
374
+ cache: { dir: cacheDir, ttlMinutes: 60 },
375
+ },
376
+ dependencies
377
+ );
378
+ clearBrowserCookieMemoryCache();
379
+ await readBrowserCookiesWithDependencies(
380
+ {
381
+ browser: 'chrome',
382
+ domainFilter: 'example.org',
383
+ cache: { dir: cacheDir, ttlMinutes: 60 },
384
+ },
385
+ dependencies
386
+ );
387
+
388
+ assert.equal(credentialReads, 1);
389
+ const cacheFiles = await readdir(cacheDir);
390
+ const credentialFile = cacheFiles.find((name) =>
391
+ name.startsWith('credential-')
392
+ );
393
+ assert.ok(credentialFile);
394
+ const credentialPath = path.join(cacheDir, credentialFile);
395
+ if (process.platform === 'win32') {
396
+ const [{ stdout: acl }, { stdout: principal }] = await Promise.all([
397
+ execFile('icacls', [credentialPath]),
398
+ execFile('whoami'),
399
+ ]);
400
+ assert.ok(acl.toLowerCase().includes(principal.trim().toLowerCase()));
401
+ assert.equal(acl.includes('(I)'), false);
402
+ } else {
403
+ assert.equal((await stat(credentialPath)).mode & 0o777, 0o600);
404
+ }
405
+ const cached = JSON.parse(await readFile(credentialPath, 'utf8'));
406
+ assert.equal(cached.kind, 'derived-key');
407
+ assert.equal(
408
+ cached.key,
409
+ pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1').toString('base64')
410
+ );
411
+ assert.equal(JSON.stringify(cached).includes(password), false);
412
+ });
413
+
414
+ it('coordinates one credential read across separate processes', async () => {
415
+ temporaryDirectory = await mkdtemp(
416
+ path.join(os.tmpdir(), 'browser-commander-cookie-process-cache-')
417
+ );
418
+ const cacheDir = path.join(temporaryDirectory, 'cache');
419
+ const credentialReads = path.join(temporaryDirectory, 'credential-reads');
420
+
421
+ await Promise.all(
422
+ Array.from({ length: 3 }, () =>
423
+ execFile(process.execPath, [
424
+ credentialWorker,
425
+ cacheDir,
426
+ credentialReads,
427
+ ])
428
+ )
429
+ );
430
+
431
+ const readers = (await readFile(credentialReads, 'utf8'))
432
+ .trim()
433
+ .split(/\r?\n/u);
434
+ assert.equal(readers.length, 1);
435
+ const credentialFile = (await readdir(cacheDir)).find((name) =>
436
+ name.startsWith('credential-')
437
+ );
438
+ const cached = JSON.parse(
439
+ await readFile(path.join(cacheDir, credentialFile), 'utf8')
440
+ );
441
+ assert.ok(cached.savedAt < Date.now() / 1000 + 1);
442
+ assert.ok(cached.savedAt > Date.now() / 1000 - 60);
443
+ });
444
+
445
+ it('expires the in-process credential cache after its TTL', async () => {
446
+ let currentTime = 1_700_000_000_000;
447
+ let credentialReads = 0;
448
+ const options = {
449
+ cache: { enabled: false, ttlSeconds: 60 },
450
+ identity: 'chrome:linux:ttl-test',
451
+ refresh: false,
452
+ metadata: {},
453
+ now: () => currentTime,
454
+ create: async () => {
455
+ credentialReads += 1;
456
+ return Buffer.alloc(16, credentialReads);
457
+ },
458
+ };
459
+
460
+ assert.equal((await getCachedCredential(options))[0], 1);
461
+ currentTime += 61_000;
462
+ assert.equal((await getCachedCredential(options))[0], 2);
463
+ assert.equal(credentialReads, 2);
464
+ });
465
+
466
+ it('decrypts Windows AES-GCM data and rejects app-bound v20 data', () => {
467
+ const key = randomBytes(32);
468
+ const host = '.example.net';
469
+ const encryptedValue = encryptGcmCookie({
470
+ host,
471
+ value: 'windows-session',
472
+ key,
473
+ });
474
+
475
+ assert.equal(
476
+ decryptChromiumCookie({
477
+ encryptedValue,
478
+ host,
479
+ databaseVersion: 24,
480
+ platform: 'win32',
481
+ key,
482
+ }),
483
+ 'windows-session'
484
+ );
485
+ assert.throws(
486
+ () =>
487
+ decryptChromiumCookie({
488
+ encryptedValue: Buffer.from('v20app-bound-cookie'),
489
+ host,
490
+ databaseVersion: 24,
491
+ platform: 'win32',
492
+ key,
493
+ }),
494
+ /app-bound.*cannot be decrypted outside the browser/i
495
+ );
496
+ });
497
+
498
+ it('removes and validates the version-24 host hash after legacy DPAPI', () => {
499
+ const host = '.legacy.example';
500
+ const plaintext = Buffer.concat([
501
+ createHash('sha256').update(host).digest(),
502
+ Buffer.from('legacy-session'),
503
+ ]);
504
+
505
+ assert.equal(
506
+ decodeChromiumCookiePlaintext({
507
+ plaintext,
508
+ host,
509
+ databaseVersion: 24,
510
+ }),
511
+ 'legacy-session'
512
+ );
513
+ assert.throws(
514
+ () =>
515
+ decodeChromiumCookiePlaintext({
516
+ plaintext,
517
+ host: '.wrong.example',
518
+ databaseVersion: 24,
519
+ }),
520
+ /domain hash does not match/i
521
+ );
522
+ });
523
+
524
+ it('uses Chromium product casing for the KWallet folder', async () => {
525
+ const calls = [];
526
+ const password = await readSafeStoragePassword({
527
+ browser: 'chrome',
528
+ platform: 'linux',
529
+ environment: {},
530
+ runCredentialCommand: async (command, args) => {
531
+ calls.push([command, args]);
532
+ if (command === 'secret-tool') {
533
+ throw new Error('libsecret unavailable');
534
+ }
535
+ return 'kwallet-password';
536
+ },
537
+ });
538
+
539
+ assert.equal(password, 'kwallet-password');
540
+ assert.deepEqual(calls[1], [
541
+ 'kwallet-query',
542
+ ['-r', 'Chrome Safe Storage', '-f', 'Chrome Keys', 'kdewallet'],
543
+ ]);
544
+ });
545
+
546
+ it('refreshes the OS credential only once for one multi-cookie import', async () => {
547
+ temporaryDirectory = await mkdtemp(
548
+ path.join(os.tmpdir(), 'browser-commander-cookie-refresh-')
549
+ );
550
+ const password = 'one refresh password';
551
+ const host = '.refresh.example';
552
+ await createChromiumProfile({
553
+ homeDir: temporaryDirectory,
554
+ rows: ['first', 'second'].map((name) => ({
555
+ host,
556
+ name,
557
+ encryptedValue: encryptCbcCookie({ host, value: name, password }),
558
+ })),
559
+ });
560
+ let credentialReads = 0;
561
+
562
+ const cookies = await readBrowserCookiesWithDependencies(
563
+ { browser: 'chrome', cache: false, refresh: true },
564
+ {
565
+ platform: 'linux',
566
+ homeDir: temporaryDirectory,
567
+ environment: {},
568
+ readSafeStoragePassword: async () => {
569
+ credentialReads += 1;
570
+ return password;
571
+ },
572
+ }
573
+ );
574
+
575
+ assert.deepEqual(
576
+ cookies.map(({ name, value }) => ({ name, value })),
577
+ [
578
+ { name: 'first', value: 'first' },
579
+ { name: 'second', value: 'second' },
580
+ ]
581
+ );
582
+ assert.equal(credentialReads, 1);
583
+ });
584
+
585
+ it('does not reuse a partial result cache for a strict import', async () => {
586
+ temporaryDirectory = await mkdtemp(
587
+ path.join(os.tmpdir(), 'browser-commander-cookie-strict-cache-')
588
+ );
589
+ await createChromiumProfile({
590
+ homeDir: temporaryDirectory,
591
+ rows: [
592
+ {
593
+ host: '.strict.example',
594
+ name: 'broken',
595
+ encryptedValue: Buffer.from('v10invalid-cbc'),
596
+ },
597
+ ],
598
+ });
599
+ const cache = {
600
+ dir: path.join(temporaryDirectory, 'cache'),
601
+ ttlMinutes: 60,
602
+ };
603
+ const dependencies = {
604
+ platform: 'linux',
605
+ homeDir: temporaryDirectory,
606
+ environment: {},
607
+ };
608
+
609
+ assert.deepEqual(
610
+ await readBrowserCookiesWithDependencies(
611
+ {
612
+ browser: 'chrome',
613
+ cache,
614
+ ignoreDecryptionErrors: true,
615
+ },
616
+ dependencies
617
+ ),
618
+ []
619
+ );
620
+ await assert.rejects(
621
+ readBrowserCookiesWithDependencies(
622
+ { browser: 'chrome', cache },
623
+ dependencies
624
+ ),
625
+ /Could not decrypt cookie broken/
626
+ );
627
+ });
628
+ });