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.
- package/CHANGELOG.md +145 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +19 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- 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
|
+
}
|