← Commit history
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]"+ } }