browser-commander 0.20.0 → 0.21.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.
Files changed (139) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,103 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathExists } from './fs-utils.js';
4
+
5
+ import { withDatabaseSnapshot } from './sqlite-snapshot.js';
6
+
7
+ /**
8
+ * History and Top Sites migration.
9
+ *
10
+ * Chrome keeps browsing history in the `History` SQLite database and the
11
+ * new-tab-page thumbnails/most-visited list in `Top Sites`. Both are open and
12
+ * write-locked while Chrome runs, so a migration takes a consistent snapshot
13
+ * through the SQLite Online Backup API (see `sqlite-snapshot.js`) and writes
14
+ * the snapshot into the target profile. The source is opened read-only and is
15
+ * never modified.
16
+ *
17
+ * The snapshot is copied verbatim rather than rebuilt row by row: `History`
18
+ * carries interdependent tables (`urls`, `visits`, `visit_source`,
19
+ * `segments`, ...) whose foreign keys must stay consistent, and the backup API
20
+ * already guarantees a point-in-time consistent copy.
21
+ */
22
+
23
+ function snapshotInto({ sourcePath, targetPath }) {
24
+ return withDatabaseSnapshot({
25
+ sourcePath,
26
+ read: async (snapshotPath) => {
27
+ const { copyFile } = await import('node:fs/promises');
28
+ await copyFile(snapshotPath, targetPath);
29
+ // Best-effort URL count for the report.
30
+ try {
31
+ const { openSqliteDatabase } =
32
+ await import('../browser-cookie-database.js');
33
+ const db = await openSqliteDatabase(targetPath, { readOnly: true });
34
+ try {
35
+ const row = db.prepare('SELECT COUNT(*) AS c FROM urls').get();
36
+ return Number(row?.c ?? 0);
37
+ } finally {
38
+ db.close();
39
+ }
40
+ } catch {
41
+ return null;
42
+ }
43
+ },
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Migrate History (and Top Sites) into the target profile.
49
+ *
50
+ * @param {Object} options
51
+ * @param {string} options.sourceProfileDir
52
+ * @param {string} options.targetProfileDir
53
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
54
+ */
55
+ export async function migrateHistory({ sourceProfileDir, targetProfileDir }) {
56
+ const skipped = [];
57
+ const warnings = [];
58
+ await mkdir(targetProfileDir, { recursive: true });
59
+
60
+ const historySource = path.join(sourceProfileDir, 'History');
61
+ let urlCount = 0;
62
+ let migratedDatabases = 0;
63
+ if (await pathExists(historySource)) {
64
+ const count = await snapshotInto({
65
+ sourcePath: historySource,
66
+ targetPath: path.join(targetProfileDir, 'History'),
67
+ });
68
+ urlCount = count ?? 0;
69
+ migratedDatabases += 1;
70
+ } else {
71
+ skipped.push({
72
+ type: 'history',
73
+ item: 'History',
74
+ reason: 'source-missing',
75
+ });
76
+ }
77
+
78
+ const topSitesSource = path.join(sourceProfileDir, 'Top Sites');
79
+ if (await pathExists(topSitesSource)) {
80
+ await snapshotInto({
81
+ sourcePath: topSitesSource,
82
+ targetPath: path.join(targetProfileDir, 'Top Sites'),
83
+ });
84
+ migratedDatabases += 1;
85
+ }
86
+
87
+ // The report counts the snapshot as one migrated unit (matching the docs
88
+ // example `"history": 1`), and carries the URL count as a warning detail so
89
+ // it is visible without changing the report shape.
90
+ if (urlCount > 0) {
91
+ warnings.push({
92
+ type: 'history',
93
+ item: 'History',
94
+ reason: 'snapshot-copied',
95
+ detail: `${urlCount} history URLs copied via the SQLite backup API`,
96
+ });
97
+ }
98
+ return {
99
+ migrated: migratedDatabases > 0 ? 1 : 0,
100
+ skipped,
101
+ warnings,
102
+ };
103
+ }
@@ -0,0 +1,307 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+
4
+ import {
5
+ browserProfileRoot,
6
+ normalizeCookieBrowser,
7
+ resolveBrowserProfile,
8
+ } from '../browser-profiles.js';
9
+ import { migrateBookmarks } from './bookmarks.js';
10
+ import { migrateCookies } from './cookies.js';
11
+ import { migrateExtensions } from './extensions.js';
12
+ import {
13
+ migrateFirefoxBookmarks,
14
+ migrateFirefoxPasswords,
15
+ readFirefoxCookies,
16
+ reportFirefoxHistory,
17
+ } from './firefox.js';
18
+ import { migrateHistory } from './history.js';
19
+ import { migratePasswords } from './passwords.js';
20
+ import { migratePreferences } from './preferences.js';
21
+ import {
22
+ createSourceKeyResolver,
23
+ localStatePathForProfile,
24
+ resolveTargetKey,
25
+ } from './os-crypt-keys.js';
26
+
27
+ /**
28
+ * Profile migration orchestrator.
29
+ *
30
+ * Copies a person's data from a real browser profile into a dedicated target
31
+ * profile, one data class at a time, and returns the report shape documented in
32
+ * `docs/cli-and-bridge.md`. Nothing is ever written to the source profile, and
33
+ * every database read goes through a consistent read-only snapshot, so the
34
+ * migration is safe to run while the source browser is open.
35
+ *
36
+ * Cookies are returned (not written): a running Chromium re-derives its own
37
+ * cookie encryption, so the launcher seeds them over CDP with the existing
38
+ * `seedCookies` path. Every other data class is written into the target profile
39
+ * directory.
40
+ */
41
+
42
+ export const ALL_DATA_CLASSES = Object.freeze([
43
+ 'cookies',
44
+ 'bookmarks',
45
+ 'history',
46
+ 'passwords',
47
+ 'preferences',
48
+ 'extensions',
49
+ ]);
50
+
51
+ const CHROMIUM_BROWSERS = new Set(['chrome', 'chromium', 'brave', 'edge']);
52
+
53
+ function emptyMigrated() {
54
+ return {
55
+ cookies: 0,
56
+ bookmarks: 0,
57
+ history: 0,
58
+ passwords: 0,
59
+ preferences: 0,
60
+ extensions: 0,
61
+ };
62
+ }
63
+
64
+ async function resolveSourceProfileDir({
65
+ browser,
66
+ profile,
67
+ userDataDir,
68
+ platform,
69
+ homeDir,
70
+ environment,
71
+ }) {
72
+ if (userDataDir) {
73
+ // For Chromium a profile lives in a named subdirectory; for Firefox the
74
+ // userDataDir already points at the profile.
75
+ if (CHROMIUM_BROWSERS.has(browser)) {
76
+ return path.join(userDataDir, profile ?? 'Default');
77
+ }
78
+ return userDataDir;
79
+ }
80
+ if (CHROMIUM_BROWSERS.has(browser)) {
81
+ const root = browserProfileRoot(browser, {
82
+ platform,
83
+ homeDir,
84
+ environment,
85
+ });
86
+ return path.join(root, profile ?? 'Default');
87
+ }
88
+ const resolved = await resolveBrowserProfile({
89
+ browser,
90
+ profile,
91
+ platform,
92
+ homeDir,
93
+ environment,
94
+ });
95
+ return resolved.path;
96
+ }
97
+
98
+ function mergeReport(report, className, fragment) {
99
+ report.migrated[className] += fragment.migrated ?? 0;
100
+ report.skipped.push(...(fragment.skipped ?? []));
101
+ report.warnings.push(...(fragment.warnings ?? []));
102
+ }
103
+
104
+ async function resolvePasswordKeys({
105
+ keys,
106
+ browser,
107
+ targetBrowser,
108
+ platform,
109
+ sourceProfileDir,
110
+ environment,
111
+ }) {
112
+ if (keys?.targetKey) {
113
+ return keys;
114
+ }
115
+ if (platform !== 'darwin' && platform !== 'linux') {
116
+ return null;
117
+ }
118
+ const { key: targetKey, prefix: targetPrefix } = await resolveTargetKey({
119
+ browser: targetBrowser,
120
+ platform,
121
+ environment,
122
+ });
123
+ const resolveSourceKey = CHROMIUM_BROWSERS.has(browser)
124
+ ? createSourceKeyResolver({
125
+ browser,
126
+ platform,
127
+ localStatePath: localStatePathForProfile(sourceProfileDir),
128
+ environment,
129
+ })
130
+ : undefined;
131
+ return { targetKey, targetPrefix, resolveSourceKey };
132
+ }
133
+
134
+ /**
135
+ * Migrate a browser profile into a dedicated target profile directory.
136
+ *
137
+ * @param {Object} options
138
+ * @param {{browser: string, profile: (string|undefined), userDataDir: (string|undefined)}} options.from
139
+ * @param {string} options.to - Target profile directory
140
+ * @param {string[]} [options.include=ALL_DATA_CLASSES]
141
+ * @param {string[]} [options.domains] - Cookie domain filter
142
+ * @param {string} [options.platform=process.platform]
143
+ * @param {string} [options.targetBrowser] - Launching browser channel (key derivation)
144
+ * @param {Object} [options.keys] - Injected password keys {resolveSourceKey, targetKey, targetPrefix, primaryPassword}
145
+ * @param {string} [options.homeDir]
146
+ * @param {Object} [options.environment]
147
+ * @returns {Promise<{source: Object, target: string, migrated: Object, skipped: Array, warnings: Array, cookies: Object[]}>}
148
+ */
149
+ export async function migrateProfile({
150
+ from,
151
+ to,
152
+ include = ALL_DATA_CLASSES,
153
+ domains,
154
+ platform = process.platform,
155
+ targetBrowser,
156
+ keys,
157
+ homeDir = os.homedir(),
158
+ environment = process.env,
159
+ }) {
160
+ if (!from?.browser) {
161
+ throw new TypeError('migrateProfile requires from.browser');
162
+ }
163
+ if (!to) {
164
+ throw new TypeError('migrateProfile requires a target directory (to)');
165
+ }
166
+ const browser = normalizeCookieBrowser(from.browser);
167
+ const profile = from.profile ?? 'Default';
168
+ const isFirefox = browser === 'firefox';
169
+ const sourceProfileDir = await resolveSourceProfileDir({
170
+ browser,
171
+ profile,
172
+ userDataDir: from.userDataDir,
173
+ platform,
174
+ homeDir,
175
+ environment,
176
+ });
177
+ const selected = new Set(include);
178
+ const report = {
179
+ source: { browser, profile, userDataDir: from.userDataDir ?? null },
180
+ target: to,
181
+ migrated: emptyMigrated(),
182
+ skipped: [],
183
+ warnings: [],
184
+ cookies: [],
185
+ };
186
+
187
+ if (selected.has('cookies')) {
188
+ if (isFirefox) {
189
+ const cookies = await readFirefoxCookies({
190
+ profileDir: sourceProfileDir,
191
+ domains,
192
+ });
193
+ report.cookies = cookies;
194
+ report.migrated.cookies = cookies.length;
195
+ } else {
196
+ const fragment = await migrateCookies({
197
+ browser,
198
+ profile,
199
+ sourceProfileDir,
200
+ domains,
201
+ readerOptions: { platform, homeDir, environment },
202
+ });
203
+ report.cookies = fragment.cookies;
204
+ report.migrated.cookies = fragment.migrated;
205
+ report.skipped.push(...fragment.skipped);
206
+ report.warnings.push(...fragment.warnings);
207
+ }
208
+ }
209
+
210
+ if (selected.has('bookmarks')) {
211
+ mergeReport(
212
+ report,
213
+ 'bookmarks',
214
+ isFirefox
215
+ ? await migrateFirefoxBookmarks({
216
+ profileDir: sourceProfileDir,
217
+ targetProfileDir: to,
218
+ })
219
+ : await migrateBookmarks({
220
+ sourceProfileDir,
221
+ targetProfileDir: to,
222
+ })
223
+ );
224
+ }
225
+
226
+ if (selected.has('history')) {
227
+ mergeReport(
228
+ report,
229
+ 'history',
230
+ isFirefox
231
+ ? await reportFirefoxHistory({ profileDir: sourceProfileDir })
232
+ : await migrateHistory({
233
+ sourceProfileDir,
234
+ targetProfileDir: to,
235
+ })
236
+ );
237
+ }
238
+
239
+ if (selected.has('preferences') && !isFirefox) {
240
+ mergeReport(
241
+ report,
242
+ 'preferences',
243
+ await migratePreferences({ sourceProfileDir, targetProfileDir: to })
244
+ );
245
+ }
246
+
247
+ if (selected.has('extensions') && !isFirefox) {
248
+ mergeReport(
249
+ report,
250
+ 'extensions',
251
+ await migrateExtensions({ sourceProfileDir, targetProfileDir: to })
252
+ );
253
+ }
254
+
255
+ if (selected.has('passwords')) {
256
+ const passwordKeys = await resolvePasswordKeys({
257
+ keys,
258
+ browser,
259
+ targetBrowser: targetBrowser ?? (isFirefox ? 'chrome' : browser),
260
+ platform,
261
+ sourceProfileDir,
262
+ environment,
263
+ });
264
+ if (!passwordKeys?.targetKey) {
265
+ report.skipped.push({
266
+ type: 'passwords',
267
+ item: 'Login Data',
268
+ reason: 'target-key-unavailable',
269
+ });
270
+ report.warnings.push({
271
+ type: 'passwords',
272
+ item: 'Login Data',
273
+ reason: 'target-key-unavailable',
274
+ detail:
275
+ 'A target encryption key was not available (on Windows the launcher must generate one and write it into the target Local State); passwords were not migrated.',
276
+ });
277
+ } else if (isFirefox) {
278
+ mergeReport(
279
+ report,
280
+ 'passwords',
281
+ await migrateFirefoxPasswords({
282
+ profileDir: sourceProfileDir,
283
+ targetProfileDir: to,
284
+ platform,
285
+ targetKey: passwordKeys.targetKey,
286
+ targetPrefix: passwordKeys.targetPrefix,
287
+ primaryPassword: passwordKeys.primaryPassword,
288
+ })
289
+ );
290
+ } else {
291
+ mergeReport(
292
+ report,
293
+ 'passwords',
294
+ await migratePasswords({
295
+ sourceProfileDir,
296
+ targetProfileDir: to,
297
+ platform,
298
+ resolveSourceKey: passwordKeys.resolveSourceKey,
299
+ targetKey: passwordKeys.targetKey,
300
+ targetPrefix: passwordKeys.targetPrefix,
301
+ })
302
+ );
303
+ }
304
+ }
305
+
306
+ return report;
307
+ }
@@ -0,0 +1,106 @@
1
+ import path from 'node:path';
2
+
3
+ import { deriveChromiumCookieKey } from '../browser-cookie-crypto.js';
4
+ import {
5
+ decryptWindowsDpapi,
6
+ readSafeStoragePassword,
7
+ readWindowsEncryptionKey,
8
+ } from '../browser-cookie-credentials.js';
9
+
10
+ /**
11
+ * Resolve the OSCrypt keys a migration needs: the source profile's key (to
12
+ * decrypt) and the target profile's key (to re-encrypt). Both sides reuse the
13
+ * cookie key handling so passwords and cookies share one implementation of the
14
+ * per-platform keystore. Every keystore call is injectable so the whole thing
15
+ * runs on Linux in unit tests.
16
+ *
17
+ * On macOS and Linux the Safe Storage key is per application, not per profile,
18
+ * so the target key is looked up for the launching browser channel. On Windows
19
+ * the target key does not exist yet: the caller generates one with
20
+ * `createWindowsProfileKey` and writes it into the target `Local State`.
21
+ */
22
+
23
+ /**
24
+ * Build a decryptor key resolver for a source profile, keyed by encryption
25
+ * prefix (Linux uses a hardcoded key for `v10` and the keyring for `v11`).
26
+ *
27
+ * @param {Object} options
28
+ * @param {string} options.browser
29
+ * @param {string} options.platform
30
+ * @param {string} options.localStatePath - Source Local State (Windows key)
31
+ * @param {Object} [options.environment]
32
+ * @param {Function} [options.readSafeStoragePassword]
33
+ * @param {Function} [options.readWindowsEncryptionKey]
34
+ * @param {Function} [options.decryptWindowsDpapi]
35
+ * @returns {function(string): Promise<Buffer>}
36
+ */
37
+ export function createSourceKeyResolver({
38
+ browser,
39
+ platform,
40
+ localStatePath,
41
+ environment = process.env,
42
+ readSafeStoragePassword: readSafeStorage = readSafeStoragePassword,
43
+ readWindowsEncryptionKey: readWindowsKey = readWindowsEncryptionKey,
44
+ decryptWindowsDpapi: decryptDpapi = decryptWindowsDpapi,
45
+ }) {
46
+ const cache = new Map();
47
+ return (prefix) => {
48
+ const cacheKey = `${prefix}`;
49
+ if (cache.has(cacheKey)) {
50
+ return cache.get(cacheKey);
51
+ }
52
+ const promise = (async () => {
53
+ if (platform === 'win32') {
54
+ return readWindowsKey({
55
+ localStatePath,
56
+ environment,
57
+ decryptDpapi,
58
+ });
59
+ }
60
+ if (platform === 'linux' && prefix === 'v10') {
61
+ return deriveChromiumCookieKey('peanuts', 'linux');
62
+ }
63
+ if (platform === 'linux' || platform === 'darwin') {
64
+ const password = await readSafeStorage({
65
+ browser,
66
+ platform,
67
+ environment,
68
+ });
69
+ return deriveChromiumCookieKey(password, platform);
70
+ }
71
+ throw new Error(`OSCrypt keys are unsupported on ${platform}`);
72
+ })();
73
+ cache.set(cacheKey, promise);
74
+ return promise;
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Resolve the target (launching) profile's encryption key on macOS/Linux.
80
+ *
81
+ * @param {Object} options
82
+ * @param {string} options.browser - Launching browser channel
83
+ * @param {string} options.platform
84
+ * @param {Object} [options.environment]
85
+ * @param {Function} [options.readSafeStoragePassword]
86
+ * @returns {Promise<{key: Buffer, prefix: string}>}
87
+ */
88
+ export async function resolveTargetKey({
89
+ browser,
90
+ platform,
91
+ environment = process.env,
92
+ readSafeStoragePassword: readSafeStorage = readSafeStoragePassword,
93
+ }) {
94
+ if (platform === 'darwin' || platform === 'linux') {
95
+ const password = await readSafeStorage({ browser, platform, environment });
96
+ return { key: deriveChromiumCookieKey(password, platform), prefix: 'v11' };
97
+ }
98
+ throw new Error(
99
+ `resolveTargetKey does not derive a Windows key; use createWindowsProfileKey`
100
+ );
101
+ }
102
+
103
+ /** Convenience: the Local State path next to a profile directory. */
104
+ export function localStatePathForProfile(profileDir) {
105
+ return path.join(path.dirname(profileDir), 'Local State');
106
+ }
@@ -0,0 +1,171 @@
1
+ import { copyFile, mkdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathExists } from './fs-utils.js';
4
+
5
+ import { decryptChromiumCookie } from '../browser-cookie-crypto.js';
6
+ import { openSqliteDatabase } from '../browser-cookie-database.js';
7
+ import { withDatabaseSnapshot } from './sqlite-snapshot.js';
8
+ import { encryptChromiumValue } from './chromium-crypto.js';
9
+
10
+ /**
11
+ * Saved-password migration.
12
+ *
13
+ * Chrome stores saved passwords in the `Login Data` SQLite database, in the
14
+ * `logins` table, with `password_value` encrypted exactly like a cookie's
15
+ * `encrypted_value` (the same OSCrypt keys), except that `Login Data` values
16
+ * never carry the SHA-256(host) domain-hash prefix the Cookies database adds at
17
+ * schema version 24.
18
+ *
19
+ * Passwords cannot be re-seeded over CDP the way cookies can, so the migration
20
+ * writes a target `Login Data` directly:
21
+ *
22
+ * 1. Take a consistent, read-only snapshot of the source `Login Data` with the
23
+ * SQLite Online Backup API (never writing to the running source).
24
+ * 2. Copy that snapshot to the target path, which preserves Chrome's exact
25
+ * schema (tables, indexes, `meta` version rows) verbatim.
26
+ * 3. Decrypt each `password_value` with the source profile's key and
27
+ * re-encrypt it with the dedicated target profile's key, updating the row
28
+ * in place. Because only the values are rewritten, the schema Chrome expects
29
+ * is untouched.
30
+ *
31
+ * The source key handling, the target key, and the platform are all injected so
32
+ * the whole path is unit-testable on Linux with a fabricated key. Windows
33
+ * app-bound `v20` values cannot be decrypted outside the browser and are
34
+ * reported in `skipped` rather than failing the migration.
35
+ */
36
+
37
+ function toBuffer(value) {
38
+ if (value === undefined || value === null) {
39
+ return Buffer.alloc(0);
40
+ }
41
+ return Buffer.isBuffer(value) ? value : Buffer.from(value);
42
+ }
43
+
44
+ function hostFromOrigin(originUrl) {
45
+ try {
46
+ return new URL(originUrl).hostname;
47
+ } catch {
48
+ return originUrl ?? '';
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Migrate saved passwords into the target profile's `Login Data`.
54
+ *
55
+ * @param {Object} options
56
+ * @param {string} options.sourceProfileDir
57
+ * @param {string} options.targetProfileDir
58
+ * @param {string} [options.platform=process.platform]
59
+ * @param {function(string): (Promise<Buffer>|Buffer)} options.resolveSourceKey
60
+ * Resolve the source decryption key for an encryption prefix (v10/v11).
61
+ * @param {Buffer} options.targetKey - The dedicated profile's encryption key.
62
+ * @param {string} [options.targetPrefix] - Version prefix to write (v10/v11).
63
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
64
+ */
65
+ export async function migratePasswords({
66
+ sourceProfileDir,
67
+ targetProfileDir,
68
+ platform = process.platform,
69
+ resolveSourceKey,
70
+ targetKey,
71
+ targetPrefix = platform === 'win32' ? 'v10' : 'v11',
72
+ }) {
73
+ const sourcePath = path.join(sourceProfileDir, 'Login Data');
74
+ if (!(await pathExists(sourcePath))) {
75
+ return {
76
+ migrated: 0,
77
+ skipped: [
78
+ { type: 'passwords', item: 'Login Data', reason: 'source-missing' },
79
+ ],
80
+ warnings: [],
81
+ };
82
+ }
83
+ if (typeof resolveSourceKey !== 'function') {
84
+ throw new TypeError(
85
+ 'migratePasswords requires a resolveSourceKey function'
86
+ );
87
+ }
88
+ if (!Buffer.isBuffer(targetKey)) {
89
+ throw new TypeError('migratePasswords requires a target encryption key');
90
+ }
91
+
92
+ await mkdir(targetProfileDir, { recursive: true });
93
+ const targetPath = path.join(targetProfileDir, 'Login Data');
94
+
95
+ return withDatabaseSnapshot({
96
+ sourcePath,
97
+ read: async (snapshotPath) => {
98
+ // The snapshot copy preserves Chrome's exact schema; re-encrypt values in
99
+ // place so the target keeps whatever schema version this Chrome build
100
+ // wrote.
101
+ await copyFile(snapshotPath, targetPath);
102
+ const db = await openSqliteDatabase(targetPath, { fileMustExist: true });
103
+ const skipped = [];
104
+ const warnings = [];
105
+ let migrated = 0;
106
+ try {
107
+ const rows = db
108
+ .prepare(
109
+ 'SELECT rowid AS rowid, origin_url, password_value FROM logins'
110
+ )
111
+ .all();
112
+ const update = db.prepare(
113
+ 'UPDATE logins SET password_value = ? WHERE rowid = ?'
114
+ );
115
+ for (const row of rows) {
116
+ const encryptedValue = toBuffer(row.password_value);
117
+ if (encryptedValue.length === 0) {
118
+ continue;
119
+ }
120
+ const prefix = encryptedValue.subarray(0, 3).toString('ascii');
121
+ if (prefix === 'v20') {
122
+ skipped.push({
123
+ type: 'passwords',
124
+ item: row.origin_url ?? '(unknown)',
125
+ reason: 'app-bound-v20',
126
+ });
127
+ continue;
128
+ }
129
+ if (prefix !== 'v10' && prefix !== 'v11') {
130
+ skipped.push({
131
+ type: 'passwords',
132
+ item: row.origin_url ?? '(unknown)',
133
+ reason: 'unsupported-encryption',
134
+ });
135
+ continue;
136
+ }
137
+ let plaintext;
138
+ try {
139
+ const key = await resolveSourceKey(prefix);
140
+ plaintext = decryptChromiumCookie({
141
+ encryptedValue,
142
+ host: hostFromOrigin(row.origin_url),
143
+ databaseVersion: 0,
144
+ platform,
145
+ key,
146
+ });
147
+ } catch (error) {
148
+ skipped.push({
149
+ type: 'passwords',
150
+ item: row.origin_url ?? '(unknown)',
151
+ reason: 'decrypt-failed',
152
+ detail: error.message,
153
+ });
154
+ continue;
155
+ }
156
+ const reencrypted = encryptChromiumValue({
157
+ plaintext,
158
+ key: targetKey,
159
+ platform,
160
+ prefix: targetPrefix,
161
+ });
162
+ update.run(reencrypted, row.rowid);
163
+ migrated += 1;
164
+ }
165
+ } finally {
166
+ db.close();
167
+ }
168
+ return { migrated, skipped, warnings };
169
+ },
170
+ });
171
+ }