browser-commander 0.20.0 → 0.21.1

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 (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,281 @@
1
+ import {
2
+ createDecipheriv,
3
+ createHash,
4
+ createHmac,
5
+ pbkdf2Sync,
6
+ } from 'node:crypto';
7
+
8
+ import { readDatabaseSnapshot } from './sqlite-snapshot.js';
9
+ import {
10
+ decodeDerElement,
11
+ decodeDerSequence,
12
+ oidToString,
13
+ } from './firefox-der.js';
14
+
15
+ /**
16
+ * NSS decryption for Firefox saved logins.
17
+ *
18
+ * Firefox does not use OSCrypt; it stores logins in `logins.json` and protects
19
+ * them with NSS. The decryption key lives in `key4.db`:
20
+ *
21
+ * - `metadata` (id `password`) holds a global salt and a password-check blob.
22
+ * - `nssPrivate` holds the encrypted 3DES key used for the actual logins.
23
+ *
24
+ * Two encryption schemes appear in the wild:
25
+ *
26
+ * - Modern Firefox wraps the key with PKCS#5 PBES2 (PBKDF2-HMAC-SHA256 then
27
+ * AES-256-CBC). The PBKDF2 password is `SHA1(globalSalt + primaryPassword)`
28
+ * and NSS stores only 14 IV bytes, so the real 16-byte CBC IV is
29
+ * `0x04 0x0e` followed by those bytes.
30
+ * - Legacy profiles use `pbeWithSha1AndTripleDES-CBC`: a SHA1-based KDF derives
31
+ * a 3DES key and IV directly from the global salt, the entry salt and the
32
+ * primary password.
33
+ *
34
+ * The individual login fields are always 3DES-CBC (`des-ede3-cbc`) under the
35
+ * recovered key. If a primary password is set and no correct one is supplied,
36
+ * the password-check fails and the caller reports `primary-password-set`
37
+ * instead of guessing.
38
+ *
39
+ * References:
40
+ * - https://searchfox.org/mozilla-central/source/security/nss (NSS sources)
41
+ * - https://github.com/unode/firefox_decrypt (the reference algorithm)
42
+ */
43
+
44
+ const OID = Object.freeze({
45
+ PBES2: '1.2.840.113549.1.5.13',
46
+ PBKDF2: '1.2.840.113549.1.5.12',
47
+ DES_EDE3_CBC: '1.2.840.113549.3.7',
48
+ PBE_SHA1_3DES: '1.2.840.113549.1.12.5.1.3',
49
+ });
50
+
51
+ const PASSWORD_CHECK = Buffer.from('password-check\u0002\u0002', 'latin1');
52
+
53
+ /** Thrown when a primary (master) password blocks decryption. */
54
+ export class PrimaryPasswordError extends Error {
55
+ constructor(message = 'Firefox primary password is set') {
56
+ super(message);
57
+ this.name = 'PrimaryPasswordError';
58
+ }
59
+ }
60
+
61
+ function derChildren(element) {
62
+ return element.children ?? decodeDerSequence(element.content);
63
+ }
64
+
65
+ function integerValue(element) {
66
+ let value = 0;
67
+ for (const byte of element.content) {
68
+ value = value * 256 + byte;
69
+ }
70
+ return value;
71
+ }
72
+
73
+ /**
74
+ * Decrypt a PBES2 (PBKDF2-HMAC-SHA256 + AES-256-CBC) blob NSS wrote.
75
+ *
76
+ * @param {Object} algorithmParams - The parsed PBES2 parameters SEQUENCE
77
+ * @param {Buffer} ciphertext
78
+ * @param {Buffer} globalSalt
79
+ * @param {Buffer} primaryPassword
80
+ * @returns {Buffer}
81
+ */
82
+ function decryptPbes2(
83
+ algorithmParams,
84
+ ciphertext,
85
+ globalSalt,
86
+ primaryPassword
87
+ ) {
88
+ const [kdf, encryptionScheme] = derChildren(algorithmParams);
89
+ const [, pbkdf2Params] = derChildren(kdf);
90
+ const params = derChildren(pbkdf2Params);
91
+ const entrySalt = params[0].content;
92
+ const iterationCount = integerValue(params[1]);
93
+ const keyLength = integerValue(params[2]);
94
+ const [, ivElement] = derChildren(encryptionScheme);
95
+
96
+ // This is not password storage: key4.db fixes the scheme as
97
+ // PBKDF2(SHA-1(globalSalt + primaryPassword)), and reading the profile
98
+ // Firefox wrote requires reproducing it byte for byte.
99
+ const passwordHash = createHash('sha1')
100
+ .update(Buffer.concat([globalSalt, primaryPassword]))
101
+ .digest();
102
+ const key = pbkdf2Sync(
103
+ passwordHash,
104
+ entrySalt,
105
+ iterationCount,
106
+ keyLength,
107
+ 'sha256'
108
+ );
109
+ const iv = Buffer.concat([Buffer.from([0x04, 0x0e]), ivElement.content]);
110
+ const decipher = createDecipheriv('aes-256-cbc', key, iv);
111
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
112
+ }
113
+
114
+ function pkcs7Unpad(buffer) {
115
+ const padLength = buffer[buffer.length - 1];
116
+ if (padLength > 0 && padLength <= buffer.length) {
117
+ return buffer.subarray(0, buffer.length - padLength);
118
+ }
119
+ return buffer;
120
+ }
121
+
122
+ /**
123
+ * Decrypt the legacy pbeWithSha1AndTripleDES-CBC key blob.
124
+ *
125
+ * @param {Object} algorithmParams - SEQUENCE { entrySalt OCTETSTRING, iterations INT }
126
+ * @param {Buffer} ciphertext
127
+ * @param {Buffer} globalSalt
128
+ * @param {Buffer} primaryPassword
129
+ * @returns {Buffer}
130
+ */
131
+ function decryptPbeSha1Triple3Des(
132
+ algorithmParams,
133
+ ciphertext,
134
+ globalSalt,
135
+ primaryPassword
136
+ ) {
137
+ const params = derChildren(algorithmParams);
138
+ const entrySalt = params[0].content;
139
+
140
+ // NSS's SHA1-based PKCS#12 KDF for pbeWithSha1AndTripleDES-CBC. The entry
141
+ // salt is right-padded with zeros to 20 bytes, and the 3DES key and IV are
142
+ // derived through three chained HMAC-SHA1 rounds. The format is fixed by
143
+ // NSS, so the SHA-1 password hash cannot be swapped for a slower one.
144
+ const hp = createHash('sha1')
145
+ .update(Buffer.concat([globalSalt, primaryPassword]))
146
+ .digest();
147
+ const pes = Buffer.concat([
148
+ entrySalt,
149
+ Buffer.alloc(Math.max(20 - entrySalt.length, 0)),
150
+ ]).subarray(0, 20);
151
+ const chp = createHash('sha1')
152
+ .update(Buffer.concat([hp, entrySalt]))
153
+ .digest();
154
+ const k1 = createHmac('sha1', chp)
155
+ .update(Buffer.concat([pes, entrySalt]))
156
+ .digest();
157
+ const tk = createHmac('sha1', chp).update(pes).digest();
158
+ const k2 = createHmac('sha1', chp)
159
+ .update(Buffer.concat([tk, entrySalt]))
160
+ .digest();
161
+ const derived = Buffer.concat([k1, k2]);
162
+ const key = derived.subarray(0, 24);
163
+ const iv = derived.subarray(derived.length - 8);
164
+ const decipher = createDecipheriv('des-ede3-cbc', key, iv);
165
+ decipher.setAutoPadding(false);
166
+ const plaintext = Buffer.concat([
167
+ decipher.update(ciphertext),
168
+ decipher.final(),
169
+ ]);
170
+ return pkcs7Unpad(plaintext);
171
+ }
172
+
173
+ function decryptNssBlob(blob, globalSalt, primaryPassword) {
174
+ const top = decodeDerElement(blob);
175
+ const [algorithmId, cipherElement] = derChildren(top);
176
+ const algorithmChildren = derChildren(algorithmId);
177
+ const oid = oidToString(algorithmChildren[0].content);
178
+ const params = algorithmChildren[1];
179
+ if (oid === OID.PBES2) {
180
+ return decryptPbes2(
181
+ params,
182
+ cipherElement.content,
183
+ globalSalt,
184
+ primaryPassword
185
+ );
186
+ }
187
+ if (oid === OID.PBE_SHA1_3DES) {
188
+ return decryptPbeSha1Triple3Des(
189
+ params,
190
+ cipherElement.content,
191
+ globalSalt,
192
+ primaryPassword
193
+ );
194
+ }
195
+ throw new Error(`Unsupported NSS key algorithm ${oid}`);
196
+ }
197
+
198
+ /**
199
+ * Recover the 3DES login key from a `key4.db` snapshot.
200
+ *
201
+ * @param {Object} db - An open, read-only key4.db handle
202
+ * @param {Buffer} primaryPassword - Empty when no primary password is set
203
+ * @returns {Buffer} The 24-byte 3DES key
204
+ */
205
+ export function recoverFirefoxKeyFromDatabase(
206
+ db,
207
+ primaryPassword = Buffer.alloc(0)
208
+ ) {
209
+ const meta = db
210
+ .prepare("SELECT item1, item2 FROM metadata WHERE id = 'password'")
211
+ .get();
212
+ if (!meta) {
213
+ throw new Error('key4.db has no password metadata');
214
+ }
215
+ const globalSalt = Buffer.from(meta.item1);
216
+ let check;
217
+ try {
218
+ check = decryptNssBlob(
219
+ Buffer.from(meta.item2),
220
+ globalSalt,
221
+ primaryPassword
222
+ );
223
+ } catch {
224
+ // A wrong or missing primary password makes the CBC padding check fail;
225
+ // treat any decryption failure of the password-check blob as a locked key.
226
+ throw new PrimaryPasswordError();
227
+ }
228
+ if (!check.subarray(0, PASSWORD_CHECK.length).equals(PASSWORD_CHECK)) {
229
+ throw new PrimaryPasswordError();
230
+ }
231
+ const priv = db.prepare('SELECT a11, a102 FROM nssPrivate').get();
232
+ if (!priv) {
233
+ throw new Error('key4.db has no nssPrivate key');
234
+ }
235
+ const decrypted = decryptNssBlob(
236
+ Buffer.from(priv.a11),
237
+ globalSalt,
238
+ primaryPassword
239
+ );
240
+ return decrypted.subarray(0, 24);
241
+ }
242
+
243
+ /**
244
+ * Decrypt one NSS-protected `logins.json` field (username or password).
245
+ *
246
+ * @param {string} base64Value - `encryptedUsername`/`encryptedPassword`
247
+ * @param {Buffer} key - The 24-byte 3DES key
248
+ * @returns {string}
249
+ */
250
+ export function decryptFirefoxField(base64Value, key) {
251
+ const top = decodeDerElement(Buffer.from(base64Value, 'base64'));
252
+ const [, algorithm, cipherElement] = derChildren(top);
253
+ const algorithmChildren = derChildren(algorithm);
254
+ const iv = algorithmChildren[1].content;
255
+ const decipher = createDecipheriv('des-ede3-cbc', key, iv);
256
+ decipher.setAutoPadding(false);
257
+ const plaintext = Buffer.concat([
258
+ decipher.update(cipherElement.content),
259
+ decipher.final(),
260
+ ]);
261
+ return pkcs7Unpad(plaintext).toString('utf8');
262
+ }
263
+
264
+ /**
265
+ * Recover the login key from a `key4.db` file path (taking a read-only
266
+ * snapshot so a running Firefox is never disturbed).
267
+ *
268
+ * @param {Object} options
269
+ * @param {string} options.key4Path
270
+ * @param {Buffer} [options.primaryPassword]
271
+ * @returns {Promise<Buffer>}
272
+ */
273
+ export function recoverFirefoxKey({
274
+ key4Path,
275
+ primaryPassword = Buffer.alloc(0),
276
+ }) {
277
+ return readDatabaseSnapshot({
278
+ sourcePath: key4Path,
279
+ read: (db) => recoverFirefoxKeyFromDatabase(db, primaryPassword),
280
+ });
281
+ }
@@ -0,0 +1,334 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { profileFileIfPresent } from './fs-utils.js';
4
+
5
+ import { openSqliteDatabase } from '../browser-cookie-database.js';
6
+ import { mapFirefoxCookieRows } from '../browser-cookies.js';
7
+ import { readDatabaseSnapshot } from './sqlite-snapshot.js';
8
+ import { encryptChromiumValue } from './chromium-crypto.js';
9
+ import { firefoxBookmarksToChrome } from './firefox-bookmarks.js';
10
+ import {
11
+ PrimaryPasswordError,
12
+ decryptFirefoxField,
13
+ recoverFirefoxKeyFromDatabase,
14
+ } from './firefox-nss.js';
15
+
16
+ /**
17
+ * Firefox → Chromium profile migration.
18
+ *
19
+ * Firefox stores its data very differently from Chromium, so each data class is
20
+ * translated rather than copied:
21
+ *
22
+ * - **cookies:** read from `cookies.sqlite` (`moz_cookies`, unencrypted) and
23
+ * returned in the same shape as the Chromium reader so the launcher can seed
24
+ * them over CDP.
25
+ * - **bookmarks:** read from `places.sqlite` (`moz_bookmarks` + `moz_places`)
26
+ * and converted to Chrome's `Bookmarks` JSON.
27
+ * - **history:** counted from `places.sqlite` and reported; it is not written,
28
+ * because Chrome's `History` schema is incompatible with Firefox's.
29
+ * - **passwords:** decrypted from `logins.json` with the NSS key in `key4.db`
30
+ * and re-encrypted into a Chrome `Login Data`. When a primary password is set
31
+ * and not supplied, they are reported as `primary-password-set`.
32
+ *
33
+ * Every database is read through a consistent read-only snapshot, so a running
34
+ * Firefox is never disturbed.
35
+ */
36
+
37
+ const FIREFOX_ROOT_GUIDS = Object.freeze({
38
+ toolbar_____: 'toolbar',
39
+ menu________: 'menu',
40
+ unfiled_____: 'unfiled',
41
+ });
42
+
43
+ // A conservative Chrome `Login Data` schema. Chrome re-keys or upgrades this on
44
+ // first launch; the columns below are the long-stable core of the `logins`
45
+ // table plus the `meta` version marker.
46
+ const CHROME_LOGINS_SCHEMA = `
47
+ CREATE TABLE meta (key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
48
+ INSERT INTO meta (key, value) VALUES ('version', '34');
49
+ INSERT INTO meta (key, value) VALUES ('last_compatible_version', '1');
50
+ CREATE TABLE logins (
51
+ origin_url VARCHAR NOT NULL,
52
+ action_url VARCHAR,
53
+ username_element VARCHAR,
54
+ username_value VARCHAR,
55
+ password_element VARCHAR,
56
+ password_value BLOB,
57
+ submit_element VARCHAR,
58
+ signon_realm VARCHAR NOT NULL,
59
+ date_created INTEGER NOT NULL,
60
+ blacklisted_by_user INTEGER NOT NULL,
61
+ scheme INTEGER NOT NULL,
62
+ password_type INTEGER,
63
+ times_used INTEGER,
64
+ form_data BLOB,
65
+ display_name VARCHAR,
66
+ icon_url VARCHAR,
67
+ federation_url VARCHAR,
68
+ skip_zero_click INTEGER,
69
+ generation_upload_status INTEGER,
70
+ possible_username_pairs BLOB,
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ date_last_used INTEGER NOT NULL DEFAULT 0,
73
+ moving_blocked_for BLOB,
74
+ date_password_modified INTEGER NOT NULL DEFAULT 0,
75
+ UNIQUE (origin_url, username_element, username_value, password_element, signon_realm)
76
+ );
77
+ `;
78
+
79
+ function signonRealm(originUrl) {
80
+ try {
81
+ const url = new URL(originUrl);
82
+ return `${url.protocol}//${url.host}/`;
83
+ } catch {
84
+ return originUrl;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Read Firefox cookies from `cookies.sqlite` in the same shape as the Chromium
90
+ * cookie reader.
91
+ *
92
+ * @param {Object} options
93
+ * @param {string} options.profileDir
94
+ * @param {string[]} [options.domains]
95
+ * @returns {Promise<Object[]>}
96
+ */
97
+ export async function readFirefoxCookies({ profileDir, domains }) {
98
+ const cookiePath = await profileFileIfPresent(profileDir, 'cookies.sqlite');
99
+ if (!cookiePath) {
100
+ return [];
101
+ }
102
+ return readDatabaseSnapshot({
103
+ sourcePath: cookiePath,
104
+ read: (db) => {
105
+ const rows = db
106
+ .prepare(
107
+ `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
108
+ FROM moz_cookies ORDER BY host, name, path`
109
+ )
110
+ .all();
111
+ // The installed-browser cookie reader owns the row → cookie mapping, so
112
+ // a migrated cookie and an imported one always have the same shape.
113
+ return mapFirefoxCookieRows(
114
+ rows.filter((row) =>
115
+ Array.isArray(domains) && domains.length > 0
116
+ ? domains.some((domain) => (row.host ?? '').includes(domain))
117
+ : true
118
+ )
119
+ );
120
+ },
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Convert Firefox bookmarks to a Chrome `Bookmarks` file in the target profile.
126
+ *
127
+ * @param {Object} options
128
+ * @param {string} options.profileDir - Firefox source profile
129
+ * @param {string} options.targetProfileDir
130
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
131
+ */
132
+ export async function migrateFirefoxBookmarks({
133
+ profileDir,
134
+ targetProfileDir,
135
+ }) {
136
+ const placesPath = await profileFileIfPresent(profileDir, 'places.sqlite');
137
+ if (!placesPath) {
138
+ return {
139
+ migrated: 0,
140
+ skipped: [
141
+ { type: 'bookmarks', item: 'places.sqlite', reason: 'source-missing' },
142
+ ],
143
+ warnings: [],
144
+ };
145
+ }
146
+ const rows = await readDatabaseSnapshot({
147
+ sourcePath: placesPath,
148
+ read: (db) =>
149
+ db
150
+ .prepare(
151
+ `SELECT b.id, b.parent, b.type, b.title, b.guid, p.url
152
+ FROM moz_bookmarks b
153
+ LEFT JOIN moz_places p ON b.fk = p.id
154
+ ORDER BY b.parent, b.position`
155
+ )
156
+ .all()
157
+ .map((row) => ({
158
+ id: Number(row.id),
159
+ parent: Number(row.parent),
160
+ type: Number(row.type),
161
+ title: row.title,
162
+ url: row.url,
163
+ root: FIREFOX_ROOT_GUIDS[row.guid] ?? null,
164
+ })),
165
+ });
166
+ const { document, count } = firefoxBookmarksToChrome({ rows });
167
+ await mkdir(targetProfileDir, { recursive: true });
168
+ await writeFile(
169
+ path.join(targetProfileDir, 'Bookmarks'),
170
+ JSON.stringify(document)
171
+ );
172
+ return { migrated: count, skipped: [], warnings: [] };
173
+ }
174
+
175
+ /**
176
+ * Count Firefox history and report it (Chrome's schema is incompatible).
177
+ *
178
+ * @param {Object} options
179
+ * @param {string} options.profileDir
180
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
181
+ */
182
+ export async function reportFirefoxHistory({ profileDir }) {
183
+ const placesPath = await profileFileIfPresent(profileDir, 'places.sqlite');
184
+ if (!placesPath) {
185
+ return { migrated: 0, skipped: [], warnings: [] };
186
+ }
187
+ const count = await readDatabaseSnapshot({
188
+ sourcePath: placesPath,
189
+ read: (db) =>
190
+ Number(db.prepare('SELECT COUNT(*) AS c FROM moz_places').get().c),
191
+ });
192
+ return {
193
+ migrated: 0,
194
+ skipped: [
195
+ {
196
+ type: 'history',
197
+ item: 'places.sqlite',
198
+ reason: 'firefox-history-schema-incompatible',
199
+ },
200
+ ],
201
+ warnings: [
202
+ {
203
+ type: 'history',
204
+ item: 'places.sqlite',
205
+ reason: 'not-migrated',
206
+ detail: `Firefox has ${count} history entries; Chrome's History schema is incompatible, so history is reported but not converted.`,
207
+ },
208
+ ],
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Decrypt Firefox logins and write them into a Chrome `Login Data`.
214
+ *
215
+ * @param {Object} options
216
+ * @param {string} options.profileDir - Firefox source profile
217
+ * @param {string} options.targetProfileDir
218
+ * @param {string} options.platform
219
+ * @param {Buffer} options.targetKey - The dedicated Chrome profile's key
220
+ * @param {string} [options.targetPrefix]
221
+ * @param {Buffer} [options.primaryPassword]
222
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
223
+ */
224
+ export async function migrateFirefoxPasswords({
225
+ profileDir,
226
+ targetProfileDir,
227
+ platform,
228
+ targetKey,
229
+ targetPrefix = platform === 'win32' ? 'v10' : 'v11',
230
+ primaryPassword = Buffer.alloc(0),
231
+ }) {
232
+ const loginsPath = await profileFileIfPresent(profileDir, 'logins.json');
233
+ const key4Path = await profileFileIfPresent(profileDir, 'key4.db');
234
+ if (!loginsPath || !key4Path) {
235
+ return {
236
+ migrated: 0,
237
+ skipped: [
238
+ { type: 'passwords', item: 'logins.json', reason: 'source-missing' },
239
+ ],
240
+ warnings: [],
241
+ };
242
+ }
243
+ if (!Buffer.isBuffer(targetKey)) {
244
+ throw new TypeError('migrateFirefoxPasswords requires a target key');
245
+ }
246
+
247
+ let key;
248
+ try {
249
+ key = await readDatabaseSnapshot({
250
+ sourcePath: key4Path,
251
+ read: (db) => recoverFirefoxKeyFromDatabase(db, primaryPassword),
252
+ });
253
+ } catch (error) {
254
+ if (error instanceof PrimaryPasswordError) {
255
+ return {
256
+ migrated: 0,
257
+ skipped: [
258
+ {
259
+ type: 'passwords',
260
+ item: 'logins.json',
261
+ reason: 'primary-password-set',
262
+ },
263
+ ],
264
+ warnings: [],
265
+ };
266
+ }
267
+ throw error;
268
+ }
269
+
270
+ const logins = JSON.parse(await readFile(loginsPath, 'utf8')).logins ?? [];
271
+ const decrypted = [];
272
+ const skipped = [];
273
+ for (const login of logins) {
274
+ try {
275
+ decrypted.push({
276
+ origin: login.hostname,
277
+ username: decryptFirefoxField(login.encryptedUsername, key),
278
+ password: decryptFirefoxField(login.encryptedPassword, key),
279
+ });
280
+ } catch (error) {
281
+ skipped.push({
282
+ type: 'passwords',
283
+ item: login.hostname ?? '(unknown)',
284
+ reason: 'decrypt-failed',
285
+ detail: error.message,
286
+ });
287
+ }
288
+ }
289
+
290
+ await mkdir(targetProfileDir, { recursive: true });
291
+ const targetPath = path.join(targetProfileDir, 'Login Data');
292
+ const db = await openSqliteDatabase(targetPath);
293
+ let migrated = 0;
294
+ try {
295
+ db.exec(CHROME_LOGINS_SCHEMA);
296
+ const insert = db.prepare(
297
+ `INSERT OR IGNORE INTO logins (
298
+ origin_url, action_url, username_element, username_value,
299
+ password_element, password_value, submit_element, signon_realm,
300
+ date_created, blacklisted_by_user, scheme, password_type, times_used,
301
+ date_last_used, date_password_modified
302
+ ) VALUES (?, ?, '', ?, '', ?, '', ?, 0, 0, 0, 0, 0, 0, 0)`
303
+ );
304
+ for (const entry of decrypted) {
305
+ const encrypted = encryptChromiumValue({
306
+ plaintext: entry.password,
307
+ key: targetKey,
308
+ platform,
309
+ prefix: targetPrefix,
310
+ });
311
+ insert.run(
312
+ entry.origin,
313
+ entry.origin,
314
+ entry.username,
315
+ encrypted,
316
+ signonRealm(entry.origin)
317
+ );
318
+ migrated += 1;
319
+ }
320
+ } finally {
321
+ db.close();
322
+ }
323
+
324
+ const warnings = [];
325
+ if (migrated > 0) {
326
+ warnings.push({
327
+ type: 'passwords',
328
+ item: 'Login Data',
329
+ reason: 'reencrypted-for-chrome',
330
+ detail: `${migrated} Firefox logins were decrypted and re-encrypted into a Chrome Login Data; Chrome may re-key the store on first launch.`,
331
+ });
332
+ }
333
+ return { migrated, skipped, warnings };
334
+ }
@@ -0,0 +1,51 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { constants as fsConstants } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Small filesystem helpers shared by the migration data-class modules.
7
+ */
8
+
9
+ /**
10
+ * Return true when a path exists (of any type).
11
+ *
12
+ * @param {string} filePath
13
+ * @returns {Promise<boolean>}
14
+ */
15
+ export async function pathExists(filePath) {
16
+ try {
17
+ await access(filePath, fsConstants.F_OK);
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Read and parse a JSON file, returning null when it is missing or malformed.
26
+ *
27
+ * @param {string} filePath
28
+ * @returns {Promise<Object|null>}
29
+ */
30
+ export async function readJsonIfPresent(filePath) {
31
+ if (!(await pathExists(filePath))) {
32
+ return null;
33
+ }
34
+ try {
35
+ return JSON.parse(await readFile(filePath, 'utf8'));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolve a file inside a profile directory, or null when it is absent.
43
+ *
44
+ * @param {string} profileDir
45
+ * @param {string} name - File name relative to the profile
46
+ * @returns {Promise<string|null>}
47
+ */
48
+ export async function profileFileIfPresent(profileDir, name) {
49
+ const filePath = path.join(profileDir, name);
50
+ return (await pathExists(filePath)) ? filePath : null;
51
+ }