← Commit history

Publish 0.3.8

John Lauer ·1508cedeca ·4mo ago ·parent 74f61be
2 files changed +44−116
bridge/server.js+2−114
@@ -26,8 +26,6 @@ const net = require('net'); const fs = require('fs'); const os = require('os'); const path = require('path');-const crypto = require('crypto');-const { execFileSync } = require('child_process');  const BRIDGE = 'native-browser'; let VERSION = '0.0.1';@@ -175,118 +173,8 @@ function queryCredentials({ browser, profile, match } = {}) {   return out; } -// ---- saved-PASSWORD decryption (path A: bridge-local, laptop-only) -----------------------------------// Decrypts ONE host's saved Chrome/Edge password so nbrowser_autologin can inject it via CDP. SECURITY:-// the plaintext exists only in this process + the frame forwarded DOWNSTREAM to the host/extension-// (loopback, same machine). It is NEVER placed in a verb result, NEVER written to a log, NEVER sent up-// the relay to the AI/container. Callers must keep it that way.-const _aesKeyCache = {}; // browser -> Buffer (DPAPI-decrypted os_crypt AES key)-// Absolute powershell path — AD spawns the bridge with a STRIPPED PATH (no System32), so bare-// `powershell` ENOENTs as "Command failed" even though standalone node finds it (verified 2026-06-23).-const PWSH = (() => { try { const p = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return fs.existsSync(p) ? p : 'powershell'; } catch { return 'powershell'; } })();-function dpapiUnprotect(buf) { // CurrentUser DPAPI via PowerShell (the bridge runs as the logged-in user)-  const b64 = buf.toString('base64');-  // -EncodedCommand (UTF-16LE base64) avoids ALL shell-quoting issues — passing PS with quotes through-  // cmd mangled the path/args (verified 2026-06-23). The script's own base64 arg is single-quoted inside.-  const ps = "Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('" + b64 + "'); "-    + "$o=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser); "-    + "[Convert]::ToBase64String($o)";-  const enc = Buffer.from(ps, 'utf16le').toString('base64');-  // AD spawns the bridge with a STRIPPED env; DPAPI CurrentUser needs USERPROFILE/APPDATA to find the-  // user master key (else "The parameter is incorrect", 2026-06-23). Reconstruct them from LOCALAPPDATA.-  const LA = process.env.LOCALAPPDATA || path.join(BROWSER_UD.chrome, '..', '..', '..');-  const UP = process.env.USERPROFILE || path.dirname(path.dirname(LA));-  const SR = process.env.SystemRoot || 'C:\\Windows';-  const childEnv = Object.assign({}, process.env, {-    USERPROFILE: UP, LOCALAPPDATA: LA, APPDATA: process.env.APPDATA || path.join(UP, 'AppData', 'Roaming'),-    SystemRoot: SR, PATH: process.env.PATH || (SR + '\\System32'),-  });-  try { // execFileSync = NO cmd shell (no quoting/PATH issues); explicit env restores the user profile DPAPI needs-    const out = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024, env: childEnv });-    return Buffer.from(out.trim(), 'base64');-  } catch (e) {-    // Decisive self-test: does DPAPI work AT ALL in the bridge's spawn context? Protect→Unprotect a dummy.-    let st = '?';-    try {-      const t = "Add-Type -AssemblyName System.Security; $s='CurrentUser'; $p=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes('rt'),$null,$s); $u=[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($p,$null,$s)); $id=[System.Security.Principal.WindowsIdentity]::GetCurrent(); Write-Output ('rt='+$u+' name='+$id.Name)";-      st = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(t, 'utf16le').toString('base64')], { encoding: 'utf8', windowsHide: true, env: childEnv }).replace(/\s+/g, ' ').trim();-    } catch (e2) { st = 'roundtrip-threw: ' + String((e2 && (e2.stderr || e2.message)) || e2).replace(/[<>]/g, '').replace(/\s+/g, ' ').slice(0, 160); }-    throw new Error('dpapi-fail selftest{' + st + '}');-  }-}-function chromeAesKey(browser) {-  if (_aesKeyCache[browser]) return _aesKeyCache[browser];-  const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8'));-  let enc = Buffer.from(ls.os_crypt.encrypted_key, 'base64');-  if (enc.slice(0, 5).toString('latin1') === 'DPAPI') enc = enc.slice(5);-  return (_aesKeyCache[browser] = dpapiUnprotect(enc));-}-function decryptBlob(blob, browser) {-  if (!blob || !blob.length) return null;-  const prefix = blob.slice(0, 3).toString('latin1');-  if (prefix === 'v10' || prefix === 'v11') { // AES-256-GCM with the os_crypt key-    const key = chromeAesKey(browser);-    const iv = blob.slice(3, 15), tag = blob.slice(blob.length - 16), ct = blob.slice(15, blob.length - 16);-    const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAuthTag(tag);-    return Buffer.concat([d.update(ct), d.final()]).toString('utf8');-  }-  return dpapiUnprotect(blob).toString('utf8'); // legacy DPAPI-only blob-}-// Read login rows INCLUDING the encrypted password blob (logins col 5) — used ONLY by autologin.-function loginRowsWithBlob(file) {-  const buf = fs.readFileSync(file);-  let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;-  const n = Math.floor(buf.length / pageSize), out = [];-  const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };-  for (let p = 1; p <= n; p++) {-    const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;-    if (buf[hdr] !== 0x0d) continue;-    const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;-    for (let c = 0; c < cells; c++) {-      let o = base + buf.readUInt16BE(ptrArr + c * 2);-      let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;-      const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;-      const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }-      let d = hEnd; const cols = [];-      for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) { sz = (s - 12) / 2; v = buf.slice(d, d + sz); } else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }-      if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '', blob: cols[5] || null });-    }-  }-  return out;-}-// Decrypt the saved password for a host in a browser profile. Returns {username, password} on success,-// else {error, candidates?} where candidates carries ONLY {host, user, blobLen, pwLen} for debugging —-// NEVER the password. SECURITY: callers MUST NOT return/log/relay the .password field.-function decryptSavedPassword({ browser, profileEmail, host, username }) {-  browser = browser || 'chrome';-  if (!BROWSER_UD[browser]) return { error: 'unknown browser ' + browser };-  const profs = profilesOf(browser);-  const pr = profileEmail ? profs.find((p) => p.email.toLowerCase() === String(profileEmail).toLowerCase()) : (profs.find((p) => p.dir === 'Default') || profs[0]);-  if (!pr) return { error: 'profile not found for ' + profileEmail, profiles: profs.map((p) => p.email || p.dir) };-  const src = path.join(BROWSER_UD[browser], pr.dir, 'Login Data');-  const tmp = path.join(os.tmpdir(), `nb-al-${browser}-${Date.now()}.db`);-  try {-    fs.copyFileSync(src, tmp);-    const rows = loginRowsWithBlob(tmp);-    const h = String(host).toLowerCase();-    const core = h.split('.').slice(-2).join('.'); // e.g. gusto.com — Gusto saves the password under app.* not login.*-    // candidates = any same-site row WITH a non-empty encrypted blob; prefer username + non-"login." host.-    let cand = rows.filter((r) => r.host && r.blob && r.blob.length && (r.host.toLowerCase().includes(h) || r.host.toLowerCase().includes(core)));-    cand.sort((a, b) => {-      const um = (r) => (username && (r.username || '').toLowerCase() === String(username).toLowerCase()) ? 1 : 0;-      if (um(b) !== um(a)) return um(b) - um(a);-      const isLogin = (r) => /(^|\.)login\./.test(r.host) ? 1 : 0; // de-prioritize the email-only login.* row-      return isLogin(a) - isLogin(b);-    });-    const seen = [];-    for (const row of cand) {-      try { const pw = decryptBlob(row.blob, browser); seen.push({ host: row.host, user: row.username, blobLen: row.blob.length, pwLen: pw ? pw.length : 0 }); if (pw && pw.length) return { username: row.username, password: pw }; }-      catch (e) { seen.push({ host: row.host, blobLen: row.blob.length, err: String((e && e.message) || e).slice(0, 600) }); }-    }-    return { error: 'no non-empty saved password matched', candidates: seen, dir: pr.dir };-  } catch (e) { dlog(`autologin decrypt error: ${(e && e.message) || e}`); return { error: String((e && e.message) || e).slice(0, 80) }; }-  finally { try { fs.unlinkSync(tmp); } catch {} }-}+// (Path-A bridge-side password decryption removed — v20 app-bound passwords can't be decrypted by any+// external process; nbrowser_autologin defers legacy-v10 decryption to the in-Chrome host. See skill.)  function registerConn(sock, profile, browser) {   // Normalize to an object + tag the browser so chrome:me and edge:me are DISTINCT coexisting conns.
page.json+42−2
@@ -1,7 +1,7 @@ {   "slug": "adom-browser-extension",   "title": "Adom Browser Extension (Chrome + Edge)",-  "summary": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup. Multi-profile driving, a read-only credential-library query (Chrome + Edge), and hands-free vendor login automation, so chip CAD + datasheets download past every login wall in your real signed-in session.",+  "summary": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the native-browser counterpart to pup. Multi-profile driving, a read-only credential-library query (Chrome + Edge), and hands-free vendor login automation, so chip CAD + datasheets download past every login wall in your real signed-in session.",   "category": "Browser Automation",   "tags": [     "browser",@@ -47,5 +47,45 @@     "type": "image",     "path": "screenshots/hero-v4.png"   },-  "visibility": "public"+  "visibility": "public",+  "version": "0.3.8",+  "type": "app",+  "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the nbrowser_* native-browser bridge (counterpart to pup). Multi-profile driving, a read-only credential-library query over the browser's own saved logins (Chrome + Edge), and hands-free vendor login automation, so chip CAD / datasheets download past every login wall in your real signed-in session.",+  "install": {+    "binary_name": "adom-browser-extension"+  },+  "files": [+    "install.sh",+    "uninstall.sh",+    "package.json",+    "page.json",+    "README.md",+    "readme.html",+    "skills/cross-platform-dev/SKILL.md",+    "skills/driving-the-extension/SKILL.md",+    "skills/driving-the-extension-demo/SKILL.md",+    "skills/driving-the-desktop/SKILL.md",+    "screenshots/hero-v4.png",+    "screenshots/demo-login.png",+    "screenshots/demo-fetch.png",+    "screenshots/show-snapmagic-login.png",+    "screenshots/show-snapmagic-in.png",+    "screenshots/show-digikey.png",+    "screenshots/show-ti.png",+    "screenshots/show-captcha.png",+    "demo/adom-native-browser-demo.mp4",+    "host/native-host.js",+    "bridge/server.js",+    "skills/driving-sites/SKILL.md",+    "skills/driving-gusto/SKILL.md"+  ],+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "dependencies": {},+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"+  } }\ No newline at end of file