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.
- package/README.md +7 -3
- package/package.json +1 -1
- 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)
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
|
|
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'] });
|