my-provider-dash-manager 0.11.0 → 0.12.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 (3) hide show
  1. package/README.md +7 -3
  2. package/package.json +1 -1
  3. package/src/main.js +158 -1
package/README.md CHANGED
@@ -218,7 +218,11 @@ works in the app. Anything else can be added by URL or as a plugin.
218
218
  refused, and a fill that cannot happen now says why instead of failing quietly.
219
219
  - **Re-importing updates changed passwords** instead of silently keeping the old one.
220
220
  - **Imports existing passwords** from installed Chromium browsers (Chrome, Edge, Brave, Vivaldi,
221
- Opera) or from a CSV exported by any browser, including Firefox.
221
+ Opera) **and from Firefox profiles directly** — no CSV export first. The *Import from browser*
222
+ window lists every Firefox profile that has saved logins alongside the Chromium ones. A Firefox
223
+ profile with a **master password** set, or one from Firefox 62 or earlier, is refused with a
224
+ specific message instead of importing nothing quietly; both cases point you at the CSV route. A
225
+ CSV exported by any browser still imports too.
222
226
  - Encrypted at rest with Windows DPAPI. Passwords never reach the app's own UI process.
223
227
  - **Origin-bound**: a login is tied to the account *and* the service's own domains. A password typed on
224
228
  another site reached from that tab (an OAuth hand-off, a look-alike page) is never captured as that
@@ -377,7 +381,7 @@ npm start # run the app
377
381
  npm run smoke # sessions, registry, partitions, tab navigation (12 checks)
378
382
  npm run smoke:restore # sessions survive a restart (real cookies)
379
383
  npm run smoke:tokens # DPAPI round-trip + live fake-token rejection per provider (15)
380
- npm run smoke:creds # import, per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (50)
384
+ npm run smoke:creds # import (CSV + direct Firefox), per-account/manager/site lists, defaults, copy, fill fallback, auto-fill plan, encryption at rest (56)
381
385
  npm run smoke:autofill # real page: form detection, fill + request ids, undo, capture, origin guard (15)
382
386
  npm run smoke:updates # version comparison, silent-install mode, auto-update toggle, real release-feed check (11)
383
387
  npm run smoke:services # every built-in login page loads inside an isolated partition (42)
@@ -390,7 +394,7 @@ npm start -- --print-paths # show the app name, version and data fold
390
394
  npm start -- --open vercel::personal # open an account on launch
391
395
  ```
392
396
 
393
- The suite is 173 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
397
+ The suite is 179 checks across eight modes. `MAM_USER_DATA=<dir>` points any command at an isolated
394
398
  profile.
395
399
 
396
400
  Releases are cut by pushing a `v*` tag: GitHub Actions checks the tag against `package.json`, builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-provider-dash-manager",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI to install and update My Provider Dash Manager: a free Windows desktop app that keeps many accounts per service (Vercel, Netlify, Supabase, Cloudflare, AWS, Google, Microsoft, GitHub, Gmail) signed in at once, each in a fully isolated session. Also available as mpdm.",
5
5
  "main": "src/main.js",
6
6
  "scripts": {
package/src/main.js CHANGED
@@ -1398,6 +1398,130 @@ async function runSmokeCreds() {
1398
1398
  } finally {
1399
1399
  try { fs.rmSync(csv, { force: true }); } catch {}
1400
1400
  }
1401
+
1402
+ // ---------- 0.12.0: direct Firefox import ----------
1403
+ // Every fixture here is built from published test constants by scripts/firefox-fixture.js. The
1404
+ // import is NEVER pointed at the real %APPDATA%\Mozilla\Firefox profile: the profile LIST is
1405
+ // redirected with MAM_FIREFOX_ROOT and every read uses an explicit synthetic folder.
1406
+ try {
1407
+ const { build: buildFixture, KNOWN_WEB } = require('../scripts/firefox-fixture.js');
1408
+ // The suite owns the whole temp dir and asserts it holds nothing but fixture INPUTS afterwards:
1409
+ // a written decrypt is the one way this path could hand plaintext to disk.
1410
+ const ffTmp = path.join(app.getPath('temp'), `mam-ff-${process.pid}`);
1411
+ fs.rmSync(ffTmp, { recursive: true, force: true });
1412
+ fs.mkdirSync(ffTmp, { recursive: true });
1413
+ try {
1414
+ const profilesRoot = path.join(ffTmp, 'ffroot');
1415
+ const profileDir = path.join(profilesRoot, 'Profiles', 'synthetic.default-release');
1416
+ buildFixture(profileDir);
1417
+ // A second profile with only key4.db, and the UTF-16LE-BOM profiles.ini Firefox really writes.
1418
+ const key4OnlyDir = path.join(profilesRoot, 'Profiles', 'empty.default');
1419
+ fs.mkdirSync(key4OnlyDir, { recursive: true });
1420
+ fs.copyFileSync(path.join(profileDir, 'key4.db'), path.join(key4OnlyDir, 'key4.db'));
1421
+ const iniText = '[General]\r\nStartWithLastProfile=1\r\nVersion=2\r\n\r\n'
1422
+ + '[Profile0]\r\nName=synthetic\r\nIsRelative=1\r\nPath=Profiles/synthetic.default-release\r\nDefault=1\r\n\r\n'
1423
+ + '[Profile1]\r\nName=empty\r\nIsRelative=1\r\nPath=Profiles/empty.default\r\n';
1424
+ fs.writeFileSync(path.join(profilesRoot, 'profiles.ini'), Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(iniText, 'utf16le')]));
1425
+
1426
+ process.env.MAM_FIREFOX_ROOT = profilesRoot;
1427
+ let listed = [];
1428
+ try { listed = creds.listFirefoxProfiles(); } finally { delete process.env.MAM_FIREFOX_ROOT; }
1429
+
1430
+ // (1) the profile list and the synthetic round trip in one check: the UTF-16LE profiles.ini is
1431
+ // read correctly, a key4.db-only profile does NOT appear as importable, and the import recovers
1432
+ // exactly the known rows with Unicode and embedded commas intact. The blank-username row and the
1433
+ // chrome:// row are both skipped and both counted.
1434
+ const profile = { browser: 'Firefox', profile: 'synthetic.default-release', profileDir };
1435
+ const r1 = creds.importFromFirefox(profile);
1436
+ const all = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1437
+ const want = new Map(KNOWN_WEB.map(k => [creds.hostOf(k.hostname), k]));
1438
+ check('firefox-synthetic-import',
1439
+ listed.length === 1 && listed[0].browser === 'Firefox'
1440
+ && listed[0].profile === 'synthetic.default-release' && listed[0].profileDir === path.resolve(profileDir)
1441
+ && r1.refused == null && r1.added === 4 && r1.updated === 0 && r1.scanned === 6
1442
+ && r1.skippedBlank === 1 && r1.skippedNonWeb === 1
1443
+ && all.length === 4
1444
+ && all.every(c => { const w = want.get(c.host); return w && w.password === c.password && w.username === c.username; })
1445
+ && all.some(c => c.password === 'p@ss"word,with,commas')
1446
+ && all.some(c => c.password === 'パスワード–✓' && c.username === 'üser@example.test')
1447
+ && all.every(c => c.source === 'Firefox: synthetic.default-release' && !!c.importedAt),
1448
+ `listed=${listed.map(p => p.profile).join(',') || 'none'} added=${r1.added} scanned=${r1.scanned} blank=${r1.skippedBlank} nonWeb=${r1.skippedNonWeb}`);
1449
+
1450
+ // (2) the frozen merge contract: a re-import with ONE changed password updates in place, keeps
1451
+ // the id, and does not add a row.
1452
+ const before = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1453
+ const targetRow = before.find(c => c.username === 'alice@example.test');
1454
+ const targetId = targetRow.id;
1455
+ // Rebuild the fixture, changing only alice's password, then re-import the same profile label.
1456
+ buildFixture(profileDir, { passwordOverrides: { 'alice@example.test': 'S3cret-Beta2!' } });
1457
+ const r2 = creds.importFromFirefox(profile);
1458
+ const afterRows = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1459
+ const aliceAfter = afterRows.find(c => c.username === 'alice@example.test');
1460
+ check('firefox-in-place-merge',
1461
+ r2.updated === 1 && r2.added === 0 && aliceAfter && aliceAfter.id === targetId
1462
+ && aliceAfter.password === 'S3cret-Beta2!' && afterRows.length === 4,
1463
+ `added=${r2.added} updated=${r2.updated} id-same=${!!aliceAfter && aliceAfter.id === targetId}`);
1464
+
1465
+ // (3) at rest: the raw store must not contain any fixture password (it is DPAPI-encrypted).
1466
+ const rawStore = fs.readFileSync(path.join(app.getPath('userData'), 'credentials.json'), 'utf8');
1467
+ const secrets = [...KNOWN_WEB.map(k => k.password), 'S3cret-Beta2!', 'only-password', 'not-a-web-login'];
1468
+ check('firefox-encrypted-at-rest',
1469
+ secrets.every(s => !rawStore.includes(s)) && rawStore.includes('"ct"'),
1470
+ `${secrets.length} fixture secrets absent; store is ${rawStore.length} B of ciphertext`);
1471
+
1472
+ // (4) refusal 1: an empty folder — honest error, zero rows added.
1473
+ const emptyDir = path.join(ffTmp, 'empty-profile');
1474
+ fs.mkdirSync(emptyDir, { recursive: true });
1475
+ const countBefore = creds.stats().total;
1476
+ const r3 = creds.importFromFirefox({ browser: 'Firefox', profile: 'empty-profile', profileDir: emptyDir });
1477
+ check('firefox-refused-missing-profile',
1478
+ r3.added === 0 && r3.refused === 'FIREFOX_NO_KEY4' && /key4\.db/.test(r3.note || '')
1479
+ && creds.stats().total === countBefore,
1480
+ `refused=${r3.refused} added=${r3.added} "${String(r3.note).slice(0, 70)}"`);
1481
+
1482
+ // (5) refusal 2: a flipped ciphertext byte in key4.db — honest error, zero rows added.
1483
+ const corruptDir = path.join(ffTmp, 'corrupt-profile');
1484
+ fs.mkdirSync(corruptDir, { recursive: true });
1485
+ buildFixture(corruptDir);
1486
+ {
1487
+ const { DatabaseSync } = require('node:sqlite');
1488
+ const db = new DatabaseSync(path.join(corruptDir, 'key4.db'));
1489
+ const meta = db.prepare("SELECT item2 FROM metaData WHERE id='password'").get();
1490
+ const blob = Buffer.from(meta.item2);
1491
+ blob[blob.length - 1] ^= 0xff; // one byte of the password-check ciphertext
1492
+ db.prepare("UPDATE metaData SET item2=? WHERE id='password'").run(blob);
1493
+ db.close();
1494
+ }
1495
+ const countBefore2 = creds.stats().total;
1496
+ const r4 = creds.importFromFirefox({ browser: 'Firefox', profile: 'corrupt-profile', profileDir: corruptDir });
1497
+ check('firefox-refused-corrupt-fixture',
1498
+ r4.added === 0 && r4.refused === 'FIREFOX_MASTER_PASSWORD' && /master password|damaged/.test(r4.note || '')
1499
+ && creds.stats().total === countBefore2,
1500
+ `refused=${r4.refused} added=${r4.added} "${String(r4.note).slice(0, 70)}"`);
1501
+
1502
+ // (6) non-web hosts are counted, never stored: the store holds no `chrome` host, AND the import
1503
+ // left nothing plaintext in the temp dir — every file there is a fixture input, and its only
1504
+ // output is the DPAPI-encrypted store.
1505
+ const storedRows = creds.loadAll().filter(c => String(c.source).startsWith('Firefox:'));
1506
+ const leftFiles = [];
1507
+ const walk = dir => { for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
1508
+ const f = path.join(dir, e.name);
1509
+ if (e.isDirectory()) walk(f); else leftFiles.push(path.relative(ffTmp, f));
1510
+ } };
1511
+ walk(ffTmp);
1512
+ const nonFixture = leftFiles.filter(f => !/(^|[\\/])(key4\.db|logins\.json|profiles\.ini)$/.test(f));
1513
+ check('firefox-skips-non-web-hosts',
1514
+ r1.skippedNonWeb === 1
1515
+ && !storedRows.some(c => c.host === 'chrome' || c.host === 'chrome://settings')
1516
+ && !creds.listCredentials().some(c => c.host === 'chrome')
1517
+ && storedRows.every(c => /^[a-z0-9.-]+$/.test(c.host) && !c.host.includes(':'))
1518
+ && nonFixture.length === 0,
1519
+ `skippedNonWeb=${r1.skippedNonWeb}; hosts=${storedRows.map(c => c.host).join(',')}; temp=${leftFiles.length} files all fixture inputs${nonFixture.length ? ` (unexpected: ${nonFixture.join(',')})` : ''}`);
1520
+ } finally { fs.rmSync(ffTmp, { recursive: true, force: true }); }
1521
+ } catch (err) {
1522
+ check('exception:firefox', false, err.message);
1523
+ }
1524
+
1401
1525
  fs.writeFileSync(path.join(__dirname, '..', 'smoke-report.json'), JSON.stringify(results, null, 2));
1402
1526
  app.exit(results.checks.every(c => c.ok) ? 0 : 1);
1403
1527
  }
@@ -2010,6 +2134,23 @@ function runInteractive(captureMode = false) {
2010
2134
  console.log('[capture] home markers:', info);
2011
2135
  await new Promise(r => setTimeout(r, 300));
2012
2136
  }
2137
+ // --demo-import: open the Import-from-browser window through the RENDERER's own click
2138
+ // handler, so the screenshot shows the real modal with whatever profile rows exist. Point
2139
+ // MAM_FIREFOX_ROOT at a synthetic root first and the Firefox row is the fixture, never the
2140
+ // owner's live profile. Screenshot aid for a scratch demo profile, like --demo-picker.
2141
+ if (process.argv.includes('--demo-import')) {
2142
+ await new Promise(r => setTimeout(r, 600));
2143
+ if (win.webContents && !win.webContents.isDestroyed()) {
2144
+ await win.webContents.executeJavaScript('document.getElementById("imp-browser").click()').catch(err => console.error('[capture] import click failed:', err.message));
2145
+ }
2146
+ await new Promise(r => setTimeout(r, 1200));
2147
+ const info = await win.webContents.executeJavaScript(`JSON.stringify({
2148
+ modalOpen: !document.getElementById('modal-back').hidden,
2149
+ rows: [...document.querySelectorAll('#modal [data-imp]')].map(b => b.innerText.trim()),
2150
+ note: (() => { const n = [...document.querySelectorAll('#modal .note')].find(x => /master password/.test(x.textContent)); return n ? n.textContent.trim().slice(0, 90) : ''; })(),
2151
+ })`).catch(err => 'probe failed: ' + err.message);
2152
+ console.log('[capture] demo import:', info);
2153
+ }
2013
2154
  // --demo-content: give the account in --capture-open one override through the REAL path, then
2014
2155
  // open its gear window from the RENDERER, so the screenshot shows the real three-way selects,
2015
2156
  // the worded resolved state and the honest note (0.11.0). Drives state from main, never from
@@ -2414,7 +2555,13 @@ function runInteractive(captureMode = false) {
2414
2555
  return { ok: false, reason: result.reason, message: messages[result.reason] || 'This page has no visible password field to fill.' };
2415
2556
  });
2416
2557
  ipcMain.handle('creds-stats', () => creds.stats());
2417
- ipcMain.handle('creds-browsers', () => creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData })));
2558
+ // One list, two kinds: a Chromium profile and a Firefox profile are both "a browser profile with
2559
+ // saved logins", so the existing Import-from-browser window renders them side by side with no new
2560
+ // surface. `kind` is what tells the renderer which import handler to call.
2561
+ ipcMain.handle('creds-browsers', () => [
2562
+ ...creds.listChromiumProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.loginData, kind: 'chromium' })),
2563
+ ...creds.listFirefoxProfiles().map(p => ({ browser: p.browser, profile: p.profile, id: p.profileDir, kind: 'firefox' })),
2564
+ ]);
2418
2565
  ipcMain.handle('creds-import-browser', async (_e, id) => {
2419
2566
  const p = creds.listChromiumProfiles().find(x => x.loginData === id);
2420
2567
  if (!p) throw new Error('Browser profile not found');
@@ -2422,6 +2569,16 @@ function runInteractive(captureMode = false) {
2422
2569
  vm.emitState();
2423
2570
  return r;
2424
2571
  });
2572
+ // Firefox: refuses a master-password-protected or old-format profile with a specific message and
2573
+ // imports nothing. A refusal is a normal result here, not an exception, so the window can show it.
2574
+ ipcMain.handle('creds-import-firefox', (_e, profileDir) => {
2575
+ const p = creds.listFirefoxProfiles().find(x => x.profileDir === profileDir)
2576
+ || (profileDir ? { browser: 'Firefox', profile: path.basename(String(profileDir)), profileDir } : null);
2577
+ if (!p) throw new Error('Firefox profile not found');
2578
+ const r = creds.importFromFirefox(p);
2579
+ vm.emitState();
2580
+ return r;
2581
+ });
2425
2582
  ipcMain.handle('creds-import-csv', async () => {
2426
2583
  const { dialog } = require('electron');
2427
2584
  const r = await dialog.showOpenDialog({ title: 'Import passwords from CSV', filters: [{ name: 'CSV', extensions: ['csv'] }], properties: ['openFile'] });