diff --git a/addon-inject-preload.js b/addon-inject-preload.js new file mode 100644 index 0000000..f4e90a8 --- /dev/null +++ b/addon-inject-preload.js @@ -0,0 +1,35 @@ +// Session-wide preload that runs every page-inject add-on's bridge script in +// the isolated world of tabs whose URL matches the add-on's declared origin +// patterns. Registered via session.defaultSession.setPreloads in main.js +// alongside bcnr-preload.js. +// +// The decision of WHICH scripts apply is made in main against the sender's +// committed URL, not against anything the page can influence. Each script +// gets a `theseus` object scoped to its add-on id: +// theseus.contextBridge — expose an API into the page's main world +// theseus.invoke(msg, payload) — call the add-on's onMessage(msg) handler +// theseus.origin — the page origin main will show the user +// plus a `require` that only resolves "electron" so scripts written in the +// ordinary preload idiom keep working. +const { contextBridge, ipcRenderer } = require("electron"); + +let injections = []; +try { injections = ipcRenderer.sendSync("addon-inject-scripts", location.href) || []; } catch {} +for (const inj of injections) { + const id = String(inj.id); + const theseus = Object.freeze({ + id, + origin: inj.origin, + contextBridge, + invoke: (msg, payload) => ipcRenderer.invoke("addon-page-msg", id, String(msg), payload), + }); + const scopedRequire = (name) => { + if (name === "electron") return { contextBridge }; + throw new Error(`addon inject scripts may only require("electron") — got ${name}`); + }; + try { + new Function("theseus", "require", inj.source)(theseus, scopedRequire); + } catch (e) { + console.warn(`[theseus] add-on "${id}" page-inject failed:`, e?.message || e); + } +} diff --git a/addons-host.js b/addons-host.js index a5f0fef..d15428d 100644 --- a/addons-host.js +++ b/addons-host.js @@ -22,7 +22,41 @@ const path = require("node:path"); // teaching main.js and (typically) the chrome renderer about the new point. // Right now only sidebar panels are wired — future rev adds toolbar-chip, // proxy, page-inject, etc. -const KNOWN_CAPABILITIES = new Set(["sidebar-panel", "session-proxy"]); +const KNOWN_CAPABILITIES = new Set([ + "sidebar-panel", "session-proxy", + // vault-derive: api.vault.derive(purposePath) — HKDF child of the password + // vault's root, namespaced under the add-on id. + // page-inject: manifest["page-inject"] = { preload, origins } — the add-on's + // preload source runs in the isolated world of every tab whose + // URL matches one of the origin patterns. + // approval-modal: api.approvalModal({...}) — user-facing consent dialog over + // the active tab, resolved by main. + "vault-derive", "page-inject", "approval-modal", +]); + +// Chrome-style match pattern → predicate. ":///" where +// scheme may be "*", host may start with "*." (matches the bare host and any +// subdomain) or be "*", and path is a glob where "*" matches anything. +// bns:// (how Theseus fetches BCNR sites internally) is folded into https:// +// so a pattern written the way the address bar shows it keeps working. +function compileOriginPattern(pattern) { + const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim()); + if (!m) throw new Error(`bad origin pattern: ${pattern}`); + const [, scheme, host, pathGlob = "/*"] = m; + const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase()); + let hostRe; + if (host === "*") hostRe = "[^/]+"; + else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`; + else hostRe = esc(host.toLowerCase()); + const pathRe = pathGlob.split("*").map(esc).join(".*"); + const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i"); + return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://")); +} +function urlMatchesAny(url, matchers) { + for (const fn of matchers) { try { if (fn(url)) return true; } catch {} } + return false; +} // Manifest field guardrails. Reject anything shape-suspicious so a bad // addon.json can't get past the loader gate. @@ -47,19 +81,40 @@ function validateManifest(raw, folderName) { // Not fatal — log later. Unknown caps are silently dropped. } } - return { id, name, version, description, author, icon, main, capabilities }; + let pageInject = null; + if (capabilities.includes("page-inject")) { + const pi = m["page-inject"]; + if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`); + const preload = String(pi.preload || ""); + if (!preload || preload.includes("..") || path.isAbsolute(preload)) { + throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`); + } + const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : []; + if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`); + pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) }; + } + return { id, name, version, description, author, icon, main, capabilities, pageInject }; } // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // of the app queries via `getActive()` / `getInstalled()`. class AddonHost { - constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy }) { + constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, approvalModal, emitToPanel, hostRequire }) { this.addonsDir = addonsDir; this.dataDir = dataDir; this.isDisabled = isDisabled || (() => false); this.log = logger || ((...a) => console.log("[addons]", ...a)); this._installed = []; // [{ manifest, folder, error? }] - this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...] } + this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject } + // Capability hooks injected by main. Each is (args..., addonId) so main + // can log/gate per add-on. Missing hook = capability unavailable. + this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; + this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; + this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; + // Add-ons live outside the app's node_modules tree, so a bare require() + // from their folder can't see Theseus's deps (ws, @noble/*, …). Main + // hands us its own require so add-ons can share the bundled tree. + this._hostRequire = typeof hostRequire === "function" ? hostRequire : null; // Session-proxy hook — injected by main so add-ons can swap the default // session's proxy rules (e.g. a "route everything through my VPS" add-on). // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise @@ -76,6 +131,7 @@ class AddonHost { discoverAndActivate() { this.ensureDirs(); + this._deactivateAll(); this._installed = []; let entries = []; try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } @@ -121,7 +177,17 @@ class AddonHost { if (!mod || typeof mod.activate !== "function") { throw new Error(`main file must export an activate(api) function`); } - const active = { manifest, folder, exports: mod, sidebarPanels: [] }; + const active = { manifest, folder, exports: mod, sidebarPanels: [], handlers: new Map(), inject: null }; + if (manifest.pageInject) { + // Read the inject source once at activation. It's shipped to every + // matching tab's preload verbatim, so a syntax error surfaces in the + // tab's console, not here — but a missing file is fatal for the add-on. + const abs = path.join(folder, manifest.pageInject.preload); + let source; + try { source = fs.readFileSync(abs, "utf8"); } + catch (e) { throw new Error(`page-inject preload not readable: ${abs} (${e?.message || e})`); } + active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; + } const api = this._makeApi(active); try { mod.activate(api); } catch (e) { throw new Error(`activate() threw: ${e?.message || e}`); } @@ -129,6 +195,16 @@ class AddonHost { this.log(`activated ${manifest.id} v${manifest.version}`); } + // Tear down every active add-on before a re-discover so long-lived state + // (sockets, timers) from a previous activation doesn't pile up. + _deactivateAll() { + for (const [id, active] of this._active) { + try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); } + catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); } + } + this._active.clear(); + } + _makeApi(active) { const { manifest, folder } = active; const storageFile = path.join(this.dataDir, `${manifest.id}.json`); @@ -183,9 +259,87 @@ class AddonHost { } await this._setSessionProxy(rules, manifest.id); }, + // Resolve a module from Theseus's own dependency tree. Add-ons run with + // the app's full privileges anyway; this only saves them from shipping + // a second copy of ws / @noble / etc. + require: (name) => { + if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`); + return this._hostRequire(name); + }, + // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, + // injected page bridges) call into the add-on with a message name + + // one JSON payload; the handler's return value goes back as the + // response. `ctx.from` is "panel" or "page"; pages also carry + // `ctx.origin` ("https://host") so the add-on can scope permissions. + onMessage: (msg, handler) => { + if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`); + active.handlers.set(msg, handler); + }, + // Push an event to the add-on's own sidebar panel if it's currently + // loaded. Fire-and-forget; silently dropped when the panel is closed. + emit: (msg, payload) => { + if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload); + }, + // vault-derive: a 32-byte HKDF child of the password vault's root, + // keyed by a path that MUST start with this add-on's id so one add-on + // can never ask for another's material. Resolves only once the user + // has unlocked the vault (main polls; the await can be long). + vault: { + derive: async (purposePath) => { + if (!manifest.capabilities.includes("vault-derive")) { + throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); + } + if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`); + const p = String(purposePath || ""); + if (!p.startsWith(manifest.id + "/") || /[^a-z0-9/._-]/i.test(p) || p.includes("..")) { + throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); + } + return this._vaultDerive(p, manifest.id); + }, + }, + // approval-modal: ask the user. Resolves to the chosen action id, or + // "cancel" (Escape / mask click / window closed). With `checkbox` set + // and ticked, the id comes back suffixed "+". + approvalModal: async (opts) => { + if (!manifest.capabilities.includes("approval-modal")) { + throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`); + } + if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`); + return this._approvalModal(opts || {}, manifest.id); + }, }; } + // Route a message to an add-on's registered handler. Callers (main) have + // already established WHO is asking; `ctx` carries that provenance. + async dispatch(id, msg, payload, ctx) { + const active = this._active.get(id); + if (!active) throw new Error(`add-on "${id}" is not active`); + const handler = active.handlers.get(String(msg)); + if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); + return handler(payload, ctx || {}); + } + hasHandler(id, msg) { + const active = this._active.get(id); + return !!(active && active.handlers.has(String(msg))); + } + // Inject scripts that apply to a tab URL — [{ id, source }]. + injectionsFor(url) { + const out = []; + for (const active of this._active.values()) { + if (active.inject && urlMatchesAny(url, active.inject.matchers)) { + out.push({ id: active.manifest.id, source: active.inject.source }); + } + } + return out; + } + // Does this add-on's page-inject declaration cover the URL? Used to gate + // page → add-on IPC so a non-matching page can't spoof a matching one. + pageAllowed(id, url) { + const active = this._active.get(id); + return !!(active && active.inject && urlMatchesAny(url, active.inject.matchers)); + } + // Read-only views for the rest of the app. snapshot() { return { @@ -213,4 +367,4 @@ class AddonHost { isActive(id) { return this._active.has(id); } } -module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest }; +module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern }; diff --git a/approval-preload.js b/approval-preload.js new file mode 100644 index 0000000..b861e17 --- /dev/null +++ b/approval-preload.js @@ -0,0 +1,9 @@ +// Preload for the add-on approval overlay (approval.html). Main pushes one +// request at a time via `approval-show`; the page answers with the chosen +// action id through `approval-pick`. Nothing else is exposed — the overlay +// is a pure consent surface. +const { contextBridge, ipcRenderer } = require("electron"); +contextBridge.exposeInMainWorld("approval", { + onShow: (cb) => ipcRenderer.on("approval-show", (_e, req) => cb(req)), + pick: (reqId, action, checked) => ipcRenderer.invoke("approval-pick", reqId, action, !!checked), +}); diff --git a/approval.html b/approval.html new file mode 100644 index 0000000..8b7c4bc --- /dev/null +++ b/approval.html @@ -0,0 +1,90 @@ + + + + +Approval + + + + + + diff --git a/main.js b/main.js index 1f98888..2e5aaa1 100644 --- a/main.js +++ b/main.js @@ -1337,6 +1337,30 @@ function initAddons() { } try { await ses.setProxy(opts); } catch (e) { console.warn("proxy set failed:", e?.message); } }, + // vault-derive capability. Resolves once the vault is unlocked (the + // user types the master password at boot or later in Settings) with a + // 32-byte HKDF child of the vault's root. The vault never persists the + // BIP-39 seed — only per-purpose roots — so add-on material hangs off + // the passwords root under an "addons/" info label: recoverable from + // the same mnemonic on any device, and a derived password can't be + // walked back to it (HKDF is one-way). + vaultDerive: async (purposePath, addonId) => { + if (!fs.existsSync(vaultFile())) throw new Error("password vault is not set up"); + while (!vaultState) await new Promise((r) => setTimeout(r, 500)); + const v = await loadVaultLib(); + const wc = require("node:crypto").webcrypto; + const key = await wc.subtle.importKey("raw", v.hexToBytes(vaultState.purposeRoot), "HKDF", false, ["deriveBits"]); + const info = new TextEncoder().encode(`silentmode/addons/${purposePath}`); + console.log(`[addons] [${addonId}] vault.derive ${purposePath}`); + return new Uint8Array(await wc.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256)); + }, + approvalModal: (opts, addonId) => showApprovalModal(opts, addonId), + emitToPanel: (addonId, msg, payload) => { + if (!sidebar || !sidebarActivePanelId || !sidebarActivePanelId.startsWith(addonId + ":")) return; + try { sidebar.webContents.send("addon-event", msg, payload); } catch {} + }, + hostRequire: (name) => require(name), }); addonHost.discoverAndActivate(); const snap = addonHost.snapshot(); @@ -1393,6 +1417,9 @@ function layout() { const tabW = Math.max(0, width - sideW); for (const t of tabs) t.view.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); if (sidebar) sidebar.setBounds({ x: tabW, y: CHROME_H, width: sideW, height: bodyH }); + // Approval overlay sits exactly over the tab area — the page underneath + // keeps running; only pointer input is intercepted. + if (approvalPop) approvalPop.setBounds({ x: 0, y: CHROME_H, width: tabW, height: bodyH }); positionPopover(); positionEnginePicker(); positionDownloads(); @@ -1976,6 +2003,13 @@ function createWindow() { sidebar = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "sidebar-preload.js") } }); win.contentView.addChildView(sidebar); sidebar.setVisible(false); + // Add-on approval overlay (approval-modal capability). Transparent view + // over the tab area, loaded once, shown per request. + approvalPop = new WebContentsView({ webPreferences: { preload: path.join(__dirname, "approval-preload.js") } }); + try { approvalPop.setBackgroundColor("#00000000"); } catch {} + win.contentView.addChildView(approvalPop); + approvalPop.webContents.loadFile("approval.html"); + approvalPop.setVisible(false); chrome.webContents.once("did-finish-load", () => { const saved = settings.restoreSession ? loadSession() : []; if (saved.length) saved.forEach((u) => createTab(u)); else createTab(); @@ -2248,6 +2282,100 @@ ipcMain.handle("addons-reload", () => { addonHost.discoverAndActivate(); return true; }); +// --- Add-on messaging + capabilities ----------------------------------------- +// Panel → add-on: the sidebar panel's file:// URL tells us which add-on it +// belongs to (same gate as storage). The add-on's onMessage handler runs in +// main and its return value is the response. +ipcMain.handle("addon-msg", async (e, msg, payload) => { + const id = addonIdForSender(e.sender); + if (!id || !addonHost) throw new Error("not an add-on panel"); + return addonHost.dispatch(id, String(msg), payload, { from: "panel" }); +}); +// Page → add-on: only a real tab whose committed URL matches the add-on's +// page-inject origins may talk to it, and only through messages the add-on +// registered. Origin is "://" (bns:// shown as https://). +function tabForSender(sender) { return tabs.find((t) => t.view.webContents === sender) || null; } +function pageOriginOf(url) { + try { + const u = new URL(String(url).replace(/^bns:\/\//i, "https://")); + return u.protocol && u.host ? `${u.protocol}//${u.host}` : null; + } catch { return null; } +} +ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => { + const tab = tabForSender(e.sender); + if (!tab || !addonHost) throw new Error("not a page"); + const url = e.sender.getURL(); + const id = String(addonId || ""); + if (!addonHost.pageAllowed(id, url)) throw new Error(`add-on "${id}" is not injected on this page`); + const origin = pageOriginOf(url); + if (!origin) throw new Error("opaque origin"); + return addonHost.dispatch(id, String(msg), payload, { from: "page", origin, tabId: tab.id }); +}); +// Synchronous — the inject preload has to know what to run before the page's +// own scripts start. Decided against the sender's committed URL; the href the +// preload reports is only logged when it disagrees. +ipcMain.on("addon-inject-scripts", (e, href) => { + e.returnValue = []; + const tab = tabForSender(e.sender); + if (!tab || !addonHost) return; + const url = e.sender.getURL(); + if (!url || url.startsWith("file:")) return; + if (href && href !== url) console.log(`[addons] inject: preload href ${href} ≠ committed ${url}`); + const origin = pageOriginOf(url); + e.returnValue = addonHost.injectionsFor(url).map((x) => ({ ...x, origin })); +}); +// Approval overlay. One request at a time; later callers queue behind the +// visible one so two dapps can't race each other for the same click. +let approvalPop = null; +const approvalQueue = []; +let approvalCurrent = null; // { reqId, resolve } +let approvalSeq = 0; +function pumpApproval() { + if (approvalCurrent || !approvalQueue.length || !approvalPop) return; + const next = approvalQueue.shift(); + approvalCurrent = next; + try { + approvalPop.webContents.send("approval-show", next.req); + approvalPop.setVisible(true); + try { win.contentView.removeChildView(approvalPop); win.contentView.addChildView(approvalPop); } catch {} + layout(); + approvalPop.webContents.focus(); + } catch (err) { + approvalCurrent = null; + next.resolve("cancel"); + console.warn("[addons] approval show failed:", err?.message); + } +} +function showApprovalModal(opts, addonId) { + const a = addonHost && addonHost.getInstalled().find((x) => x.manifest && x.manifest.id === addonId); + const req = { + reqId: ++approvalSeq, + addonId, + addonName: a ? a.manifest.name : addonId, + title: String(opts.title || "Approve?"), + body: opts.body == null ? "" : String(opts.body), + origin: opts.origin == null ? "" : String(opts.origin), + rows: Array.isArray(opts.rows) ? opts.rows.map((r) => ({ label: String(r.label ?? ""), value: String(r.value ?? ""), mono: !!r.mono, strong: !!r.strong })) : [], + actions: Array.isArray(opts.actions) ? opts.actions.map((x) => ({ id: String(x.id), label: String(x.label || x.id), primary: !!x.primary, danger: !!x.danger })) : [], + checkbox: opts.checkbox ? { id: String(opts.checkbox.id || "always"), label: String(opts.checkbox.label || "Always allow") } : null, + }; + return new Promise((resolve) => { + approvalQueue.push({ req, resolve }); + pumpApproval(); + }); +} +ipcMain.handle("approval-pick", (e, reqId, action, checked) => { + if (!approvalPop || e.sender !== approvalPop.webContents) return false; + if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false; + const cur = approvalCurrent; + approvalCurrent = null; + approvalPop.setVisible(false); + let result = String(action || "cancel"); + if (result !== "cancel" && checked && cur.req.checkbox) result += "+" + cur.req.checkbox.id; + cur.resolve(result); + pumpApproval(); + return true; +}); // --- Add-on storage (origin-gated to /addons//...) ------------ // Add-on HTML pages get storage.get/set/all via sidebar-preload.js. Main // derives the add-on id from the sender's file:// URL so a page can only @@ -3270,10 +3398,12 @@ if (!process.env.THESEUS_NO_AUTOSTART) { // called before any tab is created; whenReady runs before createWindow(). try { const bcnrPreload = path.join(__dirname, "bcnr-preload.js"); + // Add-on page-inject bridges ride the same session-wide slot; the + // preload asks main which (if any) apply to the tab it runs in. + const injectPreload = path.join(__dirname, "addon-inject-preload.js"); const existing = session.defaultSession.getPreloads(); - if (!existing.includes(bcnrPreload)) { - session.defaultSession.setPreloads([...existing, bcnrPreload]); - } + const wanted = [bcnrPreload, injectPreload].filter((p) => !existing.includes(p)); + if (wanted.length) session.defaultSession.setPreloads([...existing, ...wanted]); } catch (err) { console.warn("[bcnr] setPreloads failed:", err?.message ?? err); } protocol.handle("bns", serveBns); installDownloadTracker(); diff --git a/package.json b/package.json index 1438500..939d4f0 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "error.html", "error-preload.js", "sidebar-preload.js", + "approval-preload.js", + "approval.html", + "addon-inject-preload.js", "addons-host.js", "link-status.html", "link-status-preload.js", diff --git a/sidebar-preload.js b/sidebar-preload.js index 218b19e..794dbea 100644 --- a/sidebar-preload.js +++ b/sidebar-preload.js @@ -13,6 +13,11 @@ contextBridge.exposeInMainWorld("silentmode", { // Ask main which panel is currently visible — panels may want to // suspend expensive work when hidden. onVisibility: (cb) => ipcRenderer.on("sidebar-visibility", (_e, visible) => cb(!!visible)), + // Call into the add-on's activate() context: resolves with whatever the + // matching api.onMessage handler returned (or rejects with its error). + invoke: (msg, payload) => ipcRenderer.invoke("addon-msg", String(msg), payload), + // Events the add-on pushes via api.emit while this panel is open. + on: (msg, cb) => ipcRenderer.on("addon-event", (_e, name, payload) => { if (name === msg) cb(payload); }), }); // -- Panel picker strip -----------------------------------------------------