browser-commander 0.14.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 +12 -0
- package/README.md +96 -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/browser/launch-options.js +68 -10
- package/src/browser/launcher.js +13 -3
- package/src/browser/real-browser.js +15 -4
- package/src/core/constants.js +1 -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/tests/unit/browser/launch-options.test.js +55 -0
- package/tests/unit/browser/real-browser.test.js +19 -0
- package/tests/unit/core/constants.test.js +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.15.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- b3560c8: Apply automation-friendly Chromium launch defaults, including `--password-store=basic`, and add append/opt-out launch argument controls.
|
|
14
|
+
|
|
3
15
|
## 0.14.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -103,9 +103,17 @@ const { browser, page } = await launchBrowser({
|
|
|
103
103
|
channel: 'chrome',
|
|
104
104
|
// Or: executablePath: '/usr/bin/google-chrome',
|
|
105
105
|
headless: true,
|
|
106
|
+
extraArgs: ['--lang=en-US'],
|
|
107
|
+
// Per-flag escape hatch; `args` remains a compatible append-only alias.
|
|
108
|
+
ignoreDefaultArgs: ['--disable-infobars'],
|
|
106
109
|
});
|
|
107
110
|
```
|
|
108
111
|
|
|
112
|
+
Browser Commander applies the documented
|
|
113
|
+
[automation-friendly launch defaults](../docs/feature-parity.md#automation-friendly-launch-defaults),
|
|
114
|
+
including `--password-store=basic` to avoid an additional OS credential
|
|
115
|
+
dialog. `ignoreDefaultArgs: true` omits every Browser Commander default.
|
|
116
|
+
|
|
109
117
|
Attach to a Chrome-family browser that is already listening for CDP connections:
|
|
110
118
|
|
|
111
119
|
```javascript
|
|
@@ -118,6 +126,10 @@ const { browser, page } = await connectBrowser({
|
|
|
118
126
|
const commander = makeBrowserCommander({ page });
|
|
119
127
|
```
|
|
120
128
|
|
|
129
|
+
Attachment cannot change the existing process's launch arguments. Start it
|
|
130
|
+
with a loopback remote-debugging port, a dedicated `--user-data-dir`, and the
|
|
131
|
+
automation-friendly defaults above, or use `launchRealBrowser()`.
|
|
132
|
+
|
|
121
133
|
`launchRealBrowser()` can find and start a genuine installed Chrome,
|
|
122
134
|
Edge, Brave, or Chromium with a loopback CDP endpoint and then attach to it. It
|
|
123
135
|
always uses a dedicated profile; Chrome 136 and newer do not honor remote
|
|
@@ -131,6 +143,8 @@ const connection = await launchRealBrowser({
|
|
|
131
143
|
engine: 'puppeteer',
|
|
132
144
|
channel: 'chrome',
|
|
133
145
|
userDataDir: '/tmp/my-automation-profile',
|
|
146
|
+
extraArgs: ['--lang=en-US'],
|
|
147
|
+
ignoreDefaultArgs: ['--disable-infobars'],
|
|
134
148
|
seedCookies: [
|
|
135
149
|
{ name: 'session', value: 'saved', url: 'https://example.com' },
|
|
136
150
|
],
|
|
@@ -138,9 +152,44 @@ const connection = await launchRealBrowser({
|
|
|
138
152
|
await connection.browser.close();
|
|
139
153
|
```
|
|
140
154
|
|
|
141
|
-
Cookie seeding copies only cookies you explicitly provide
|
|
142
|
-
|
|
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.
|
|
143
190
|
`launchAndConnectRealBrowser()` remains available as a descriptive alias.
|
|
191
|
+
Remote-debugging, loopback, and profile arguments remain managed even when all
|
|
192
|
+
optional defaults are ignored; `headless: true` adds `--headless=new`.
|
|
144
193
|
|
|
145
194
|
Reuse a saved authenticated session by passing Playwright-compatible storage
|
|
146
195
|
state as a JSON file path or object. Cookies and localStorage are restored for
|
|
@@ -371,6 +420,51 @@ default browser-profile paths, protects its remote-debugging arguments, and
|
|
|
371
420
|
returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path,
|
|
372
421
|
and profile path alongside `{ browser, page }`.
|
|
373
422
|
`launchAndConnectRealBrowser()` is an alias with identical behavior.
|
|
423
|
+
`extraArgs` appends custom switches, while `ignoreDefaultArgs` accepts a list
|
|
424
|
+
of defaults to omit (or `true` to omit all optional defaults). The older
|
|
425
|
+
`args` append option remains supported.
|
|
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.
|
|
374
468
|
|
|
375
469
|
### saveStorageState(page, filePath)
|
|
376
470
|
|
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
|
+
}
|