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.
- package/CHANGELOG.md +6 -0
- package/README.md +77 -2
- package/package.json +3 -2
- package/src/browser/browser-cookie-cache.js +278 -0
- package/src/browser/browser-cookie-credentials.js +148 -0
- package/src/browser/browser-cookie-crypto.js +128 -0
- package/src/browser/browser-cookie-database.js +35 -0
- package/src/browser/browser-cookies.js +343 -0
- package/src/browser/browser-profiles.js +280 -0
- package/src/exports.js +4 -0
- package/tests/fixtures/browser-cookie-cache-worker.mjs +21 -0
- package/tests/unit/browser/browser-cookies.test.js +628 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a23c0fa: Add installed-browser profile discovery and privacy-preserving cookie import for Chrome, Edge, Brave, Chromium, and Firefox, including OS credential caching and Playwright/Puppeteer-compatible output.
|
|
8
|
+
|
|
3
9
|
## 0.15.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -152,8 +152,41 @@ const connection = await launchRealBrowser({
|
|
|
152
152
|
await connection.browser.close();
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
-
Cookie seeding copies only cookies you explicitly provide
|
|
156
|
-
|
|
155
|
+
Cookie seeding copies only cookies you explicitly provide. To seed a dedicated
|
|
156
|
+
profile from one of your installed browser profiles, use the explicit local
|
|
157
|
+
cookie-import helper:
|
|
158
|
+
|
|
159
|
+
```javascript
|
|
160
|
+
import {
|
|
161
|
+
launchAndConnectRealBrowser,
|
|
162
|
+
listBrowserProfiles,
|
|
163
|
+
readBrowserCookies,
|
|
164
|
+
} from 'browser-commander';
|
|
165
|
+
|
|
166
|
+
console.log(await listBrowserProfiles({ browser: 'chrome' }));
|
|
167
|
+
|
|
168
|
+
const cookies = await readBrowserCookies({
|
|
169
|
+
browser: 'chrome', // chrome, edge, brave, chromium, or firefox
|
|
170
|
+
profile: 'Default',
|
|
171
|
+
domainFilter: 'example.com',
|
|
172
|
+
cache: { ttlMinutes: 60 },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const connection = await launchAndConnectRealBrowser({
|
|
176
|
+
engine: 'playwright',
|
|
177
|
+
channel: 'chrome',
|
|
178
|
+
userDataDir: '/tmp/my-dedicated-profile',
|
|
179
|
+
seedCookies: cookies,
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
The import stays on the local machine and runs only when called. It never sends
|
|
184
|
+
cookie data anywhere. Decrypted result and derived-key cache files are stored
|
|
185
|
+
under `~/.browser-commander/cookie-cache/` with owner-only permissions. The
|
|
186
|
+
default 60-minute TTL and a cross-process lock ensure that concurrent or repeated
|
|
187
|
+
scripts touch Keychain, libsecret/KWallet, or DPAPI at most once per TTL window.
|
|
188
|
+
Set `refresh: true` to force a new read, customize `cache.dir`/`ttlMinutes`, or
|
|
189
|
+
set `cache: false` to opt out of disk caching.
|
|
157
190
|
`launchAndConnectRealBrowser()` remains available as a descriptive alias.
|
|
158
191
|
Remote-debugging, loopback, and profile arguments remain managed even when all
|
|
159
192
|
optional defaults are ignored; `headless: true` adds `--headless=new`.
|
|
@@ -391,6 +424,48 @@ and profile path alongside `{ browser, page }`.
|
|
|
391
424
|
of defaults to omit (or `true` to omit all optional defaults). The older
|
|
392
425
|
`args` append option remains supported.
|
|
393
426
|
|
|
427
|
+
### listBrowserProfiles(options)
|
|
428
|
+
|
|
429
|
+
Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox.
|
|
430
|
+
Pass an optional `browser` to narrow discovery. Each result contains
|
|
431
|
+
`{ browser, name, displayName, path, isDefault }`.
|
|
432
|
+
|
|
433
|
+
### readBrowserCookies(options)
|
|
434
|
+
|
|
435
|
+
Read cookies from an installed browser profile and return the exact
|
|
436
|
+
`{ name, value, domain, path, expires, httpOnly, secure, sameSite }` shape used by
|
|
437
|
+
Playwright and Puppeteer:
|
|
438
|
+
|
|
439
|
+
```javascript
|
|
440
|
+
const cookies = await readBrowserCookies({
|
|
441
|
+
browser: 'firefox',
|
|
442
|
+
profile: 'default-release', // optional; the default profile is selected first
|
|
443
|
+
domainFilter: 'example.com', // optional substring match
|
|
444
|
+
cache: { dir: './private-cookie-cache', ttlMinutes: 30 },
|
|
445
|
+
refresh: false,
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Platform support:
|
|
450
|
+
|
|
451
|
+
| Browser family | macOS | Linux | Windows |
|
|
452
|
+
| ----------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
453
|
+
| Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (`v10`/`v11`) | libsecret/KWallet + AES-128-CBC (`v11`), or the Chromium `v10` fallback key | DPAPI-protected AES-256-GCM key (`v10`/`v11`) |
|
|
454
|
+
| Firefox | `cookies.sqlite` | `cookies.sqlite` | `cookies.sqlite` |
|
|
455
|
+
|
|
456
|
+
Firefox cookie values are stored directly in its local cookie database. Chromium
|
|
457
|
+
database version 24 domain hashes and Chrome's 1601-based timestamps are handled
|
|
458
|
+
automatically. Current Windows Chromium can use app-bound `v20` encryption,
|
|
459
|
+
which intentionally requires the browser's privileged elevation service and
|
|
460
|
+
cannot be decrypted by an ordinary external process. The helper reports that
|
|
461
|
+
boundary instead of bypassing it; use a browser-supported export or an existing
|
|
462
|
+
Browser Commander storage-state file for those cookies. Set
|
|
463
|
+
`ignoreDecryptionErrors: true` only when returning the remaining decryptable
|
|
464
|
+
cookies is acceptable.
|
|
465
|
+
|
|
466
|
+
Treat imported cookies like passwords: keep cache directories private, use a
|
|
467
|
+
short TTL, never commit them, and seed only a dedicated automation profile.
|
|
468
|
+
|
|
394
469
|
### saveStorageState(page, filePath)
|
|
395
470
|
|
|
396
471
|
Save the current cookies and localStorage in Playwright's portable storage
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-commander",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
+
"better-sqlite3": "^12.11.1",
|
|
66
67
|
"log-lazy": "^1.0.4",
|
|
67
68
|
"test-anywhere": "^0.9.1"
|
|
68
69
|
},
|
|
@@ -72,8 +73,8 @@
|
|
|
72
73
|
"eslint-config-prettier": "^10.1.8",
|
|
73
74
|
"eslint-plugin-prettier": "^5.5.4",
|
|
74
75
|
"husky": "^9.1.7",
|
|
75
|
-
"jsdoc": "^4.0.4",
|
|
76
76
|
"jscpd": "^4.0.5",
|
|
77
|
+
"jsdoc": "^4.0.4",
|
|
77
78
|
"lint-staged": "^16.2.6",
|
|
78
79
|
"playwright": "^1.56.1",
|
|
79
80
|
"prettier": "^3.6.2",
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TTL_MINUTES = 60;
|
|
16
|
+
const LOCK_STALE_MILLISECONDS = 30_000;
|
|
17
|
+
const LOCK_WAIT_MILLISECONDS = 30_000;
|
|
18
|
+
const credentialPromises = new Map();
|
|
19
|
+
const execFile = promisify(execFileCallback);
|
|
20
|
+
let windowsPrincipalPromise;
|
|
21
|
+
|
|
22
|
+
function hash(value) {
|
|
23
|
+
return createHash('sha256').update(value).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearBrowserCookieMemoryCache() {
|
|
27
|
+
credentialPromises.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeCookieCache(cache, homeDir, ttlMinutes) {
|
|
31
|
+
const selectedTtl = ttlMinutes ?? cache?.ttlMinutes ?? DEFAULT_TTL_MINUTES;
|
|
32
|
+
if (!Number.isFinite(selectedTtl) || selectedTtl < 0) {
|
|
33
|
+
throw new RangeError(
|
|
34
|
+
'cookie cache ttlMinutes must be a non-negative number'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (cache === false) {
|
|
38
|
+
return { enabled: false, ttlSeconds: selectedTtl * 60 };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
enabled: true,
|
|
42
|
+
dir: path.resolve(
|
|
43
|
+
cache?.dir ?? path.join(homeDir, '.browser-commander', 'cookie-cache')
|
|
44
|
+
),
|
|
45
|
+
ttlSeconds: selectedTtl * 60,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function windowsPrincipal() {
|
|
50
|
+
windowsPrincipalPromise ??= execFile('whoami', [], {
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
}).then(({ stdout }) => stdout.trim());
|
|
53
|
+
return windowsPrincipalPromise;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function restrictOwnerOnly(targetPath, directory) {
|
|
57
|
+
if (process.platform === 'win32') {
|
|
58
|
+
const principal = await windowsPrincipal();
|
|
59
|
+
if (!principal) {
|
|
60
|
+
throw new Error('Could not identify the current Windows user');
|
|
61
|
+
}
|
|
62
|
+
const permission = directory ? '(OI)(CI)F' : 'F';
|
|
63
|
+
await execFile(
|
|
64
|
+
'icacls',
|
|
65
|
+
[
|
|
66
|
+
targetPath,
|
|
67
|
+
'/inheritance:r',
|
|
68
|
+
'/grant:r',
|
|
69
|
+
`${principal}:${permission}`,
|
|
70
|
+
'/q',
|
|
71
|
+
],
|
|
72
|
+
{ windowsHide: true }
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await chmod(targetPath, directory ? 0o700 : 0o600);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function ensureCacheDirectory(cacheDir) {
|
|
80
|
+
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
|
|
81
|
+
await restrictOwnerOnly(cacheDir, true);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readFreshJson(filePath, ttlSeconds, now) {
|
|
85
|
+
try {
|
|
86
|
+
const value = JSON.parse(await readFile(filePath, 'utf8'));
|
|
87
|
+
const age = now() / 1000 - value.savedAt;
|
|
88
|
+
return age >= 0 && age <= ttlSeconds ? value : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function writeOwnerOnlyJson(filePath, value) {
|
|
95
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
96
|
+
const handle = await open(temporaryPath, 'wx', 0o600);
|
|
97
|
+
try {
|
|
98
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8');
|
|
99
|
+
await handle.sync();
|
|
100
|
+
} finally {
|
|
101
|
+
await handle.close();
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await rename(temporaryPath, filePath);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await rm(temporaryPath, { force: true });
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
await restrictOwnerOnly(filePath, false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function cachePath(cacheDir, kind, identity) {
|
|
113
|
+
return path.join(cacheDir, `${kind}-${hash(identity)}.json`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function readCookieResultCache({
|
|
117
|
+
cache,
|
|
118
|
+
identity,
|
|
119
|
+
refresh,
|
|
120
|
+
now = Date.now,
|
|
121
|
+
}) {
|
|
122
|
+
if (!cache.enabled || refresh) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
const cached = await readFreshJson(
|
|
126
|
+
cachePath(cache.dir, 'cookies', identity),
|
|
127
|
+
cache.ttlSeconds,
|
|
128
|
+
now
|
|
129
|
+
);
|
|
130
|
+
return cached?.kind === 'cookies' && Array.isArray(cached.cookies)
|
|
131
|
+
? cached.cookies
|
|
132
|
+
: null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function writeCookieResultCache({
|
|
136
|
+
cache,
|
|
137
|
+
identity,
|
|
138
|
+
cookies,
|
|
139
|
+
now = Date.now,
|
|
140
|
+
}) {
|
|
141
|
+
if (!cache.enabled) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
await ensureCacheDirectory(cache.dir);
|
|
145
|
+
await writeOwnerOnlyJson(cachePath(cache.dir, 'cookies', identity), {
|
|
146
|
+
version: 1,
|
|
147
|
+
kind: 'cookies',
|
|
148
|
+
savedAt: now() / 1000,
|
|
149
|
+
cookies,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function removeStaleLock(lockPath, now) {
|
|
154
|
+
try {
|
|
155
|
+
const lockStat = await stat(lockPath);
|
|
156
|
+
if (now() - lockStat.mtimeMs > LOCK_STALE_MILLISECONDS) {
|
|
157
|
+
await rm(lockPath, { force: true });
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Another process may have released it already.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function wait(milliseconds) {
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function acquireCredentialLock(lockPath, cachedPath, options) {
|
|
169
|
+
const startedAt = options.now();
|
|
170
|
+
while (options.now() - startedAt <= LOCK_WAIT_MILLISECONDS) {
|
|
171
|
+
try {
|
|
172
|
+
return await open(lockPath, 'wx', 0o600);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error.code !== 'EEXIST') {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
const cached = await readFreshJson(
|
|
178
|
+
cachedPath,
|
|
179
|
+
options.ttlSeconds,
|
|
180
|
+
options.now
|
|
181
|
+
);
|
|
182
|
+
if (
|
|
183
|
+
cached?.kind === 'derived-key' &&
|
|
184
|
+
(!options.refresh || cached.savedAt !== options.initialSavedAt)
|
|
185
|
+
) {
|
|
186
|
+
return { cached };
|
|
187
|
+
}
|
|
188
|
+
await removeStaleLock(lockPath, options.now);
|
|
189
|
+
await wait(50);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
throw new Error('timed out waiting for another cookie credential reader');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function loadOrCreateCredential({
|
|
196
|
+
cache,
|
|
197
|
+
identity,
|
|
198
|
+
refresh,
|
|
199
|
+
create,
|
|
200
|
+
now,
|
|
201
|
+
metadata,
|
|
202
|
+
}) {
|
|
203
|
+
if (!cache.enabled) {
|
|
204
|
+
return { key: Buffer.from(await create()), savedAt: now() / 1000 };
|
|
205
|
+
}
|
|
206
|
+
await ensureCacheDirectory(cache.dir);
|
|
207
|
+
const cachedPath = cachePath(cache.dir, 'credential', identity);
|
|
208
|
+
const lockPath = `${cachedPath}.lock`;
|
|
209
|
+
const initial = await readFreshJson(cachedPath, cache.ttlSeconds, now);
|
|
210
|
+
if (!refresh && initial?.kind === 'derived-key') {
|
|
211
|
+
return {
|
|
212
|
+
key: Buffer.from(initial.key, 'base64'),
|
|
213
|
+
savedAt: initial.savedAt,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const acquired = await acquireCredentialLock(lockPath, cachedPath, {
|
|
218
|
+
initialSavedAt: initial?.savedAt,
|
|
219
|
+
now,
|
|
220
|
+
refresh,
|
|
221
|
+
ttlSeconds: cache.ttlSeconds,
|
|
222
|
+
});
|
|
223
|
+
if (acquired.cached) {
|
|
224
|
+
return {
|
|
225
|
+
key: Buffer.from(acquired.cached.key, 'base64'),
|
|
226
|
+
savedAt: acquired.cached.savedAt,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const afterLock = await readFreshJson(cachedPath, cache.ttlSeconds, now);
|
|
232
|
+
if (
|
|
233
|
+
afterLock?.kind === 'derived-key' &&
|
|
234
|
+
(!refresh || afterLock.savedAt !== initial?.savedAt)
|
|
235
|
+
) {
|
|
236
|
+
return {
|
|
237
|
+
key: Buffer.from(afterLock.key, 'base64'),
|
|
238
|
+
savedAt: afterLock.savedAt,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const key = Buffer.from(await create());
|
|
242
|
+
const savedAt = now() / 1000;
|
|
243
|
+
await writeOwnerOnlyJson(cachedPath, {
|
|
244
|
+
version: 1,
|
|
245
|
+
kind: 'derived-key',
|
|
246
|
+
savedAt,
|
|
247
|
+
key: key.toString('base64'),
|
|
248
|
+
...metadata,
|
|
249
|
+
});
|
|
250
|
+
return { key, savedAt };
|
|
251
|
+
} finally {
|
|
252
|
+
await acquired.close();
|
|
253
|
+
await rm(lockPath, { force: true });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function getCachedCredential(options) {
|
|
258
|
+
const memoryIdentity = `${options.cache.dir ?? 'disabled'}:${options.identity}`;
|
|
259
|
+
const now = options.now ?? Date.now;
|
|
260
|
+
if (!options.refresh && credentialPromises.has(memoryIdentity)) {
|
|
261
|
+
const cached = await credentialPromises.get(memoryIdentity);
|
|
262
|
+
const age = now() / 1000 - cached.savedAt;
|
|
263
|
+
if (age >= 0 && age <= options.cache.ttlSeconds) {
|
|
264
|
+
return cached.key;
|
|
265
|
+
}
|
|
266
|
+
credentialPromises.delete(memoryIdentity);
|
|
267
|
+
}
|
|
268
|
+
const promise = loadOrCreateCredential({ ...options, now });
|
|
269
|
+
credentialPromises.set(memoryIdentity, promise);
|
|
270
|
+
try {
|
|
271
|
+
return (await promise).key;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (credentialPromises.get(memoryIdentity) === promise) {
|
|
274
|
+
credentialPromises.delete(memoryIdentity);
|
|
275
|
+
}
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
const execFile = promisify(execFileCallback);
|
|
6
|
+
|
|
7
|
+
const SAFE_STORAGE = {
|
|
8
|
+
brave: {
|
|
9
|
+
application: 'brave',
|
|
10
|
+
folder: 'Brave Keys',
|
|
11
|
+
service: 'Brave Safe Storage',
|
|
12
|
+
},
|
|
13
|
+
chrome: {
|
|
14
|
+
application: 'chrome',
|
|
15
|
+
folder: 'Chrome Keys',
|
|
16
|
+
service: 'Chrome Safe Storage',
|
|
17
|
+
},
|
|
18
|
+
chromium: {
|
|
19
|
+
application: 'chromium',
|
|
20
|
+
folder: 'Chromium Keys',
|
|
21
|
+
service: 'Chromium Safe Storage',
|
|
22
|
+
},
|
|
23
|
+
edge: {
|
|
24
|
+
application: 'microsoft-edge',
|
|
25
|
+
folder: 'Microsoft Edge Keys',
|
|
26
|
+
service: 'Microsoft Edge Safe Storage',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function runCredentialCommand(command, args, environment) {
|
|
31
|
+
const { stdout } = await execFile(command, args, {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
env: environment,
|
|
34
|
+
maxBuffer: 1024 * 1024,
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
});
|
|
37
|
+
return stdout.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readLinuxSafeStoragePassword(browser, environment, runCommand) {
|
|
41
|
+
const identity = SAFE_STORAGE[browser];
|
|
42
|
+
try {
|
|
43
|
+
const password = await runCommand(
|
|
44
|
+
'secret-tool',
|
|
45
|
+
['lookup', 'application', identity.application],
|
|
46
|
+
environment
|
|
47
|
+
);
|
|
48
|
+
if (password) {
|
|
49
|
+
return password;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Try KWallet before reporting that v11 key storage is unavailable.
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const password = await runCommand(
|
|
56
|
+
'kwallet-query',
|
|
57
|
+
['-r', identity.service, '-f', identity.folder, 'kdewallet'],
|
|
58
|
+
environment
|
|
59
|
+
);
|
|
60
|
+
if (password) {
|
|
61
|
+
return password;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Fall through to the actionable error below.
|
|
65
|
+
}
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Could not read ${identity.service} from libsecret or KWallet; install secret-tool or unlock the browser key store`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Read a Chromium Safe Storage password from the platform credential store. */
|
|
72
|
+
export async function readSafeStoragePassword({
|
|
73
|
+
browser,
|
|
74
|
+
platform = process.platform,
|
|
75
|
+
environment = process.env,
|
|
76
|
+
runCredentialCommand: runCommand = runCredentialCommand,
|
|
77
|
+
}) {
|
|
78
|
+
const identity = SAFE_STORAGE[browser];
|
|
79
|
+
if (!identity) {
|
|
80
|
+
throw new Error(`No Safe Storage identity is known for ${browser}`);
|
|
81
|
+
}
|
|
82
|
+
if (platform === 'darwin') {
|
|
83
|
+
const password = await runCommand(
|
|
84
|
+
'security',
|
|
85
|
+
['find-generic-password', '-w', '-s', identity.service],
|
|
86
|
+
environment
|
|
87
|
+
);
|
|
88
|
+
if (!password) {
|
|
89
|
+
throw new Error(`${identity.service} returned an empty password`);
|
|
90
|
+
}
|
|
91
|
+
return password;
|
|
92
|
+
}
|
|
93
|
+
if (platform === 'linux') {
|
|
94
|
+
return readLinuxSafeStoragePassword(browser, environment, runCommand);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Safe Storage passwords are not used on ${platform}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DPAPI_SCRIPT = [
|
|
100
|
+
'$inputBytes=[Convert]::FromBase64String($args[0])',
|
|
101
|
+
'$outputBytes=[Security.Cryptography.ProtectedData]::Unprotect(',
|
|
102
|
+
' $inputBytes,$null,',
|
|
103
|
+
' [Security.Cryptography.DataProtectionScope]::CurrentUser)',
|
|
104
|
+
'[Convert]::ToBase64String($outputBytes)',
|
|
105
|
+
].join(';');
|
|
106
|
+
|
|
107
|
+
/** Decrypt bytes with Windows DPAPI in the current user's security context. */
|
|
108
|
+
export async function decryptWindowsDpapi(
|
|
109
|
+
encryptedValue,
|
|
110
|
+
{ environment = process.env } = {}
|
|
111
|
+
) {
|
|
112
|
+
const output = await runCredentialCommand(
|
|
113
|
+
'powershell.exe',
|
|
114
|
+
[
|
|
115
|
+
'-NoLogo',
|
|
116
|
+
'-NoProfile',
|
|
117
|
+
'-NonInteractive',
|
|
118
|
+
'-Command',
|
|
119
|
+
DPAPI_SCRIPT,
|
|
120
|
+
Buffer.from(encryptedValue).toString('base64'),
|
|
121
|
+
],
|
|
122
|
+
environment
|
|
123
|
+
);
|
|
124
|
+
return Buffer.from(output, 'base64');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Recover the legacy AES-GCM key from a Chromium Local State file. */
|
|
128
|
+
export async function readWindowsEncryptionKey({
|
|
129
|
+
localStatePath,
|
|
130
|
+
environment = process.env,
|
|
131
|
+
decryptDpapi = decryptWindowsDpapi,
|
|
132
|
+
}) {
|
|
133
|
+
let state;
|
|
134
|
+
try {
|
|
135
|
+
state = JSON.parse(await readFile(localStatePath, 'utf8'));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new Error(`Could not read Chromium Local State: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
const encoded = state.os_crypt?.encrypted_key;
|
|
140
|
+
if (!encoded) {
|
|
141
|
+
throw new Error('Chromium Local State has no os_crypt.encrypted_key');
|
|
142
|
+
}
|
|
143
|
+
const encryptedKey = Buffer.from(encoded, 'base64');
|
|
144
|
+
if (encryptedKey.subarray(0, 5).toString('ascii') !== 'DPAPI') {
|
|
145
|
+
throw new Error('Chromium Local State key does not have a DPAPI prefix');
|
|
146
|
+
}
|
|
147
|
+
return decryptDpapi(encryptedKey.subarray(5), { environment });
|
|
148
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDecipheriv,
|
|
3
|
+
createHash,
|
|
4
|
+
pbkdf2Sync,
|
|
5
|
+
timingSafeEqual,
|
|
6
|
+
} from 'node:crypto';
|
|
7
|
+
import { TextDecoder } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const CHROMIUM_CBC_IV = Buffer.alloc(16, 0x20);
|
|
10
|
+
const DOMAIN_HASH_BYTES = 32;
|
|
11
|
+
|
|
12
|
+
export function deriveChromiumCookieKey(password, platform) {
|
|
13
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
14
|
+
throw new Error(`CBC cookie keys are not used on ${platform}`);
|
|
15
|
+
}
|
|
16
|
+
return pbkdf2Sync(
|
|
17
|
+
password,
|
|
18
|
+
'saltysalt',
|
|
19
|
+
platform === 'darwin' ? 1003 : 1,
|
|
20
|
+
16,
|
|
21
|
+
'sha1'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function removeDomainHash(plaintext, host, databaseVersion) {
|
|
26
|
+
if (databaseVersion < 24) {
|
|
27
|
+
return plaintext;
|
|
28
|
+
}
|
|
29
|
+
if (plaintext.length < DOMAIN_HASH_BYTES) {
|
|
30
|
+
throw new Error('decrypted cookie is missing its domain hash');
|
|
31
|
+
}
|
|
32
|
+
const expectedHash = createHash('sha256').update(host).digest();
|
|
33
|
+
const actualHash = plaintext.subarray(0, DOMAIN_HASH_BYTES);
|
|
34
|
+
if (!timingSafeEqual(actualHash, expectedHash)) {
|
|
35
|
+
throw new Error('decrypted cookie domain hash does not match its host');
|
|
36
|
+
}
|
|
37
|
+
return plaintext.subarray(DOMAIN_HASH_BYTES);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decodeCookieValue(plaintext) {
|
|
41
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate and decode plaintext returned by a Chromium platform decryptor. */
|
|
45
|
+
export function decodeChromiumCookiePlaintext({
|
|
46
|
+
plaintext,
|
|
47
|
+
host,
|
|
48
|
+
databaseVersion = 0,
|
|
49
|
+
}) {
|
|
50
|
+
return decodeCookieValue(
|
|
51
|
+
removeDomainHash(Buffer.from(plaintext), host, databaseVersion)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function decryptCbcCookie(encryptedValue, key) {
|
|
56
|
+
const decipher = createDecipheriv('aes-128-cbc', key, CHROMIUM_CBC_IV);
|
|
57
|
+
return Buffer.concat([
|
|
58
|
+
decipher.update(encryptedValue.subarray(3)),
|
|
59
|
+
decipher.final(),
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function decryptGcmCookie(encryptedValue, key) {
|
|
64
|
+
const payload = encryptedValue.subarray(3);
|
|
65
|
+
if (payload.length < 12 + 16) {
|
|
66
|
+
throw new Error('encrypted AES-GCM cookie is truncated');
|
|
67
|
+
}
|
|
68
|
+
const nonce = payload.subarray(0, 12);
|
|
69
|
+
const authenticationTag = payload.subarray(-16);
|
|
70
|
+
const ciphertext = payload.subarray(12, -16);
|
|
71
|
+
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
|
72
|
+
decipher.setAuthTag(authenticationTag);
|
|
73
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Decrypt a Chromium cookie encrypted_value using an already recovered key. */
|
|
77
|
+
export function decryptChromiumCookie({
|
|
78
|
+
encryptedValue,
|
|
79
|
+
host,
|
|
80
|
+
databaseVersion = 0,
|
|
81
|
+
platform = process.platform,
|
|
82
|
+
key,
|
|
83
|
+
}) {
|
|
84
|
+
const encrypted = Buffer.from(encryptedValue);
|
|
85
|
+
const prefix = encrypted.subarray(0, 3).toString('ascii');
|
|
86
|
+
if (prefix === 'v20') {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'Windows app-bound v20 cookies cannot be decrypted outside the browser; use a browser-supported export or a previously saved storage state'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (prefix !== 'v10' && prefix !== 'v11') {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'cookie does not use a supported Chromium encryption prefix'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (!Buffer.isBuffer(key)) {
|
|
97
|
+
throw new TypeError('a recovered cookie encryption key is required');
|
|
98
|
+
}
|
|
99
|
+
const plaintext =
|
|
100
|
+
platform === 'win32'
|
|
101
|
+
? decryptGcmCookie(encrypted, key)
|
|
102
|
+
: decryptCbcCookie(encrypted, key);
|
|
103
|
+
return decodeChromiumCookiePlaintext({
|
|
104
|
+
plaintext,
|
|
105
|
+
host,
|
|
106
|
+
databaseVersion,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function chromiumSameSite(value) {
|
|
111
|
+
if (Number(value) === 2) {
|
|
112
|
+
return 'Strict';
|
|
113
|
+
}
|
|
114
|
+
if (Number(value) === 1) {
|
|
115
|
+
return 'Lax';
|
|
116
|
+
}
|
|
117
|
+
return 'None';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function firefoxSameSite(value) {
|
|
121
|
+
if (Number(value) === 2) {
|
|
122
|
+
return 'Strict';
|
|
123
|
+
}
|
|
124
|
+
if (Number(value) === 1) {
|
|
125
|
+
return 'Lax';
|
|
126
|
+
}
|
|
127
|
+
return 'None';
|
|
128
|
+
}
|