← Commit history

Credential import goes LIVE against AD 1.9.220's desktop_decrypt_browser_credentials. Vault (credential_vault.js) now identity-tagged: keep BOTH on a (host,username) conflict from different source accounts, newest-wins within an identity, getCredentialForUrl picks by preferred identity then most-recently-used; passwords in keytar under per-entry composite accounts, index never holds a password (offline-tested). Dashboard Import button calls the decrypt helper LOCALLY (bridge->AD direct API, never relay), handles consent_declined/elevation_declined/relay_forbidden/helper_missing, merges rows, drops plaintext, returns counts only.

John Lauer ·e68eb142ad ·3mo ago ·parent 5d20955
5 files changed +151−35
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "1.9.324",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.324/adom-bridge-puppeteer-v1.9.324.zip",-  "sha256": "c5bfbac67569a4de444bf8cb138b57a91b2663f8b822725a8c1f5a4a114aae7d",-  "size": 618942,+  "version": "1.9.325",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.9.325/adom-bridge-puppeteer-v1.9.325.zip",+  "sha256": "c32e248aa71aedfef68e10df11607338178d38ced39062e27689f423fe0e4e00",+  "size": 621510,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.324+1.9.325
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "1.9.324",+  "version": "1.9.325",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. browser_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. browser_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, browser_alert_window posts a native toast whose click focuses the window, browser_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-desktop",   "author": "Adom Inc.",
src/credential_vault.js+94−19
@@ -122,7 +122,16 @@ async function setCredential({ host, username, password }) {   return { ok: true, host, username }; } -async function getCredentialForUrl(url) {+// The keytar account key that holds THIS entry's password. Imported (identity-tagged)+// entries use a per-entry composite so BOTH sides of a host+user conflict from different+// source accounts can coexist; legacy single-host entries fall back to the host key.+function keytarAccountFor(entry) { return entry.keytarAccount || entry.host; }++// v1.9.325: a host can now have MULTIPLE tagged credentials (personal + work + media, from+// the browser import). Pick the best: best host-pattern match first, then the preferred+// identity if the caller gave one (opts.preferAccount — e.g. the pup window's owner), then+// most-recently-used. Returns the chosen entry's sourceAccount so the caller knows the persona.+async function getCredentialForUrl(url, opts = {}) {   if (!keytar) return null;   let host;   try {@@ -131,26 +140,86 @@ async function getCredentialForUrl(url) {     return null;   }   const entries = readIndex();-  let best = null;-  let bestScore = -1;+  const matches = [];   for (const e of entries) {     const s = scorePattern(e.host, host);-    if (s > bestScore) {-      bestScore = s;-      best = e;-    }+    if (s >= 0) matches.push({ e, s });   }-  if (!best) return null;-  const password = await keytar.getPassword(KEYTAR_SERVICE, best.host);+  if (!matches.length) return null;+  const prefer = opts.preferAccount ? String(opts.preferAccount).toLowerCase() : null;+  matches.sort((a, b) => {+    if (b.s !== a.s) return b.s - a.s;                                  // best host pattern+    if (prefer) {+      const ap = (a.e.sourceAccount || '').toLowerCase() === prefer ? 1 : 0;+      const bp = (b.e.sourceAccount || '').toLowerCase() === prefer ? 1 : 0;+      if (bp !== ap) return bp - ap;                                    // preferred identity+    }+    const at = a.e.lastUsed || a.e.updatedAt || '';+    const bt = b.e.lastUsed || b.e.updatedAt || '';+    return String(bt).localeCompare(String(at));                       // most-recently-used+  });+  const best = matches[0].e;+  const password = await keytar.getPassword(KEYTAR_SERVICE, keytarAccountFor(best));   if (!password) return null;-  return { host: best.host, username: best.username, password };+  return { host: best.host, username: best.username, password, sourceAccount: best.sourceAccount || null };+}++// ── Bulk import from the user's REAL browsers (the adom-you seed) ────────────+// Rows come from AD's desktop_decrypt_browser_credentials (the SYSTEM-elevated ABE decrypt,+// AD >= 1.9.220). Each row: {origin, username, password, sourceBrowser, sourceProfile,+// sourceAccount, lastUsed}. DECISION (locked with John): keep BOTH sides of a (host,username)+// conflict when they come from DIFFERENT source accounts, so the agent can act as the RIGHT+// persona per site; same (host,username,sourceAccount) is an update (newest wins). Passwords go+// to keytar under a per-entry composite account; the index NEVER holds a password. The caller+// hands us plaintext ONCE and must drop it after — we return COUNTS ONLY, never plaintext.+function hostFromOrigin(origin) {+  if (!origin) return null;+  try { return new URL(origin).hostname || String(origin); } catch { return String(origin); }+}+function importAccountKey(host, username, sourceAccount) {+  return `imp${host}${username || ''}${sourceAccount || ''}`;+}+async function mergeImportedCredentials(rows) {+  if (!keytar) throw new Error(`keytar is not installed in the bridge dir. Original load error: ${keytarLoadError}`);+  if (!Array.isArray(rows)) return { added: 0, updated: 0, skipped: 0, total: 0 };+  const entries = readIndex();+  const now = new Date().toISOString();+  let added = 0, updated = 0, skipped = 0;+  for (const r of rows) {+    try {+      const host = hostFromOrigin(r && (r.origin || r.host));+      const username = (r && r.username) || '';+      const password = r && r.password;+      if (!host || !password) { skipped++; continue; }+      const acct = importAccountKey(host, username, r.sourceAccount);+      await keytar.setPassword(KEYTAR_SERVICE, acct, password);+      const idx = entries.findIndex(e => keytarAccountFor(e) === acct);+      const meta = {+        host, username, keytarAccount: acct,+        sourceAccount: r.sourceAccount || null,+        sourceBrowser: r.sourceBrowser || null,+        sourceProfile: r.sourceProfile || null,+        imported: true,+        lastUsed: r.lastUsed || null,+      };+      if (idx >= 0) { entries[idx] = { ...entries[idx], ...meta, updatedAt: now }; updated++; }+      else { entries.push({ ...meta, addedAt: now, updatedAt: now }); added++; }+    } catch (e) { skipped++; }+  }+  writeIndex(entries);+  return { added, updated, skipped, total: rows.length }; }  function listCredentials() {-  // Index already excludes passwords by construction+  // Index already excludes passwords by construction. Now also surfaces the identity tags+  // from an import so the dashboard can show WHICH persona each saved login belongs to.   return readIndex().map(e => ({     host: e.host,     username: e.username,+    sourceAccount: e.sourceAccount || null,+    sourceBrowser: e.sourceBrowser || null,+    sourceProfile: e.sourceProfile || null,+    imported: !!e.imported,     addedAt: e.addedAt,     updatedAt: e.updatedAt,   }));@@ -158,22 +227,27 @@ function listCredentials() {  async function deleteCredential(host) {   if (!host) throw new Error('host required');+  const entries = readIndex();+  const toRemove = entries.filter(e => e.host === host);   let removed = false;   if (keytar) {     try {-      removed = await keytar.deletePassword(KEYTAR_SERVICE, host);+      if (toRemove.length) {+        for (const e of toRemove) {+          const r = await keytar.deletePassword(KEYTAR_SERVICE, keytarAccountFor(e));+          removed = removed || r;+        }+      } else {+        removed = await keytar.deletePassword(KEYTAR_SERVICE, host);   // legacy host-keyed+      }     } catch (e) {       console.log(`[credential_vault] keytar deletePassword error: ${e.message}`);     }   }-  const entries = readIndex();-  const before = entries.length;   const next = entries.filter(e => e.host !== host);-  if (next.length !== before) {-    writeIndex(next);-    return { ok: true, host, removedFromKeychain: removed, removedFromIndex: true };-  }-  return { ok: true, host, removedFromKeychain: removed, removedFromIndex: false };+  const removedFromIndex = next.length !== entries.length;+  if (removedFromIndex) writeIndex(next);+  return { ok: true, host, removedFromKeychain: removed, removedFromIndex }; }  // Apply stored creds to a page given the target URL it's about to load.@@ -255,6 +329,7 @@ async function autofillFormFromVault(page, url, { submit = false } = {}) {  module.exports = {   setCredential,+  mergeImportedCredentials,   getCredentialForUrl,   listCredentials,   deleteCredential,
src/server.js+51−10
@@ -9357,16 +9357,20 @@ function importSectionHtml(){     +'<div style="display:flex;gap:8px;margin:12px 0;flex-wrap:wrap"><button class="btn sm" id="importScanBtn">'+(s?'Rescan browsers':'Scan my browsers')+'</button>'     +(has?'<button class="btn sm" id="importRunBtn">Import selected logins</button>':'')+'</div>'     +summary+rows-    +(has?'<div id="importStatus" class="sdesc" style="opacity:.6;margin-top:10px">Decrypting these needs a one-time Windows admin approval, handled by Adom Desktop core (issue #51). Discovery works today; one-click import lights up when that lands.</div>':'')+    +(has?'<div id="importStatus" class="sdesc" style="opacity:.6;margin-top:10px">Import pops one Adom Desktop consent dialog + one Windows admin (UAC) prompt, then decrypts your selected logins into the adom-you vault, tagged by account. Passwords are never shown or logged.</div>':'')     +'</div>'; } async function runImportScan(){const b=_q('#importScanBtn');if(b){b.disabled=true;b.textContent='Scanning…';}const j=await act({action:'import-scan'});_importScan=(j&&j.ok)?j:{profiles:[],accounts:[]};renderDlg();} async function runImport(){   const sel=((_importScan&&_importScan.profiles)||[]).filter(p=>_importSel[p.browser+'/'+p.profileDir]!==false);   if(!sel.length){toast('Select at least one profile','error');return;}+  const btn=_q('#importRunBtn');if(btn){btn.disabled=true;btn.textContent='Approve on your desktop…';}+  const el=_q('#importStatus');if(el)el.textContent='Waiting for the Adom Desktop consent dialog and Windows admin (UAC) prompt on your screen…';   const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'import-run',profiles:sel.map(p=>({browser:p.browser,profileDir:p.profileDir}))})});-  const j=await r.json().catch(()=>({}));const el=_q('#importStatus');if(el)el.textContent=j.message||j.error||'Requested.';-  toast(j.pending?'Import pending the Windows admin-approval helper (AD #51)':'Import requested','info');+  const j=await r.json().catch(()=>({}));+  if(btn){btn.disabled=false;btn.textContent='Import selected logins';}+  if(el)el.textContent=j.message||j.error||'Done.';+  toast(j.ok?('Imported '+((j.added||0)+(j.updated||0))+' logins into the adom-you vault'):(j.message||j.error||'Import failed'),j.ok?'success':'error'); } function renderDlg(){   const st=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};@@ -9573,13 +9577,50 @@ const server = http.createServer(async (req, res) => {           } catch (e) { return done({ ok: false, error: e.message }); }         }         if (act === 'import-run') {-          // The elevated SYSTEM decrypt of ABE (v20) passwords is AD-core (adom/adom-desktop #51).-          // A user process (this bridge) CANNOT decrypt them, so never fake a success — report the-          // honest pending state. When the AD helper lands, this becomes: one UAC → decrypt the-          // selected profiles → merge identity-tagged rows into the adom-you vault.+          // AD >= 1.9.220 ships desktop_decrypt_browser_credentials (the SYSTEM-elevated ABE decrypt,+          // adom/adom-desktop #51). It MUST be called LOCALLY: chrome.adCommand routes over AD's+          // direct API (127.0.0.1), which is exactly the loopback path AD requires — a relayed call is+          // refused with errorCode:relay_forbidden and pops no UAC. AD fires its OWN consent dialog ++          // UAC prompt (that IS the user's permission for the decrypt). Plaintext returns ONCE over+          // loopback; we merge it into the identity-tagged vault and DROP it. NEVER log a password or+          // echo it into another verb/response — this handler returns COUNTS ONLY.           const sel = Array.isArray(a.profiles) ? a.profiles : [];-          console.log(`[import] run requested for ${sel.length} profile(s) — awaiting AD-core decrypt helper (#51)`);-          return done({ ok: true, pending: true, selected: sel.length, message: `Discovery is done. Decrypting ${sel.length} profile(s) needs a one-time Windows admin approval, handled by the Adom Desktop core helper being built now (issue #51). One-click import completes the moment that lands — nothing was decrypted or stored yet.` });+          const browsers = [...new Set(sel.map(p => p && p.browser).filter(Boolean))];+          if (!browsers.length) return done({ ok: false, error: 'no profiles selected' });+          console.log(`[import] decrypt requested for ${sel.length} profile(s) across ${browsers.join(', ')} — calling AD helper locally (consent + UAC will prompt on the desktop)`);+          let resp;+          try {+            resp = await chrome.adCommand('desktop_decrypt_browser_credentials',+              { browsers, include: ['passwords'] }, { timeoutMs: 180000 });+          } catch (e) { return done({ ok: false, error: 'decrypt helper call failed: ' + (e && e.message || e) }); }+          const payload = adPayload(resp) || {};+          const ec = payload.errorCode || (resp && resp.errorCode);+          if (ec) {+            const emap = {+              relay_forbidden: 'The decrypt helper refused a relayed call (pup must call it locally). This is a pup bug — please report it.',+              consent_declined: 'You declined the Adom Desktop consent dialog. Nothing was read.',+              elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',+              helper_missing: 'The signed decrypt helper is not installed — update or repair Adom Desktop.',+            };+            return done({ ok: false, errorCode: ec, message: emap[ec] || ('Decrypt failed: ' + ec) });+          }+          const pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];+          if (!payload.ok && !pwRows.length) return done({ ok: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') });+          // Honor per-profile deselection: keep rows whose (browser, profile) was selected; if the+          // profile keys don't line up with AD's naming, fall back to browser-level (never drop all).+          const wanted = new Set(sel.filter(p => p && p.profileDir).map(p => `${p.browser}${p.profileDir}`));+          let rows = wanted.size ? pwRows.filter(r => r && wanted.has(`${r.sourceBrowser}${r.sourceProfile}`)) : pwRows.slice();+          if (!rows.length) rows = pwRows.filter(r => r && browsers.includes(r.sourceBrowser));+          let merge;+          try { merge = await credentialVault.mergeImportedCredentials(rows); }+          catch (e) { return done({ ok: false, error: 'vault merge failed: ' + (e && e.message || e) }); }+          // DROP plaintext immediately — zero every returned password so nothing lingers in memory/response.+          try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; } catch (e) {}+          const st = payload.stats || {};+          console.log(`[import] vault merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped (AD stats profilesRead=${st.profilesRead} passwordsOk=${st.passwordsOk} passwordsFailed=${st.passwordsFailed})`);+          return done({ ok: true, imported: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,+            stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed, cookiesFailed: st.cookiesFailed },+            message: `Imported ${merge.added + merge.updated} logins into your adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''}), tagged by source account. pup can now sign in as you on those sites.` });         }         if (act === 'set-setting') {           // v1.9.259: the dashboard settings dialog. Whitelisted keys only; values validated.@@ -12919,7 +12960,7 @@ const server = http.createServer(async (req, res) => {             accounts: accts,             pythonAvailable: !!scan.pythonAvailable,             _hint: profiles.length-              ? `Found ${profiles.length} browser profile(s) across ${new Set(profiles.map(p=>p.browser)).size} browser(s), ~${totalPw} saved password(s), identities: ${accts.join(', ') || 'none tagged'}.${anyAbe ? ' These are App-Bound-Encrypted (v20) — a USER process (this bridge) canNOT decrypt them.' : ''} This verb is DISCOVERY ONLY. Actual decryption needs the AD-core elevated SYSTEM helper (adom/adom-desktop #51, desktop_decrypt_browser_credentials): one UAC prompt → unwraps each browser's ABE master key → decrypts every profile's v20 passwords → returns identity-tagged rows, which pup then merges into the adom-you vault (credential_vault.js). ${scan.pythonAvailable ? '' : 'NOTE: python not found on PATH, so passwordCount is null — only file presence/size known. '}Next: the Settings "Import logins from Chrome/Edge" picker reads this list; the import button invokes the AD helper once #51 lands.`+              ? `Found ${profiles.length} browser profile(s) across ${new Set(profiles.map(p=>p.browser)).size} browser(s), ~${totalPw} saved password(s), identities: ${accts.join(', ') || 'none tagged'}.${anyAbe ? ' These are App-Bound-Encrypted (v20) — a USER process (this bridge) canNOT decrypt them.' : ''} This verb is DISCOVERY ONLY. The actual decrypt is AD's desktop_decrypt_browser_credentials (AD >= 1.9.220): the dashboard "Import logins" button calls it LOCALLY (bridge → AD direct API), the user approves one consent dialog + one UAC prompt, and pup merges the identity-tagged rows into the adom-you vault (credential_vault.js) and drops the plaintext. ${scan.pythonAvailable ? '' : 'NOTE: python not found on PATH, so passwordCount is null — only file presence/size known. '}Next: open the Settings "Import logins from Chrome/Edge" picker, which reads this list.`               : `No Chromium browser profiles with a Preferences file found under %LOCALAPPDATA% (Chrome/Edge/Brave). Nothing to import.`,           });           return;