From 80fe82abee65afc7c93bc220ac4bf64d299ca105 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 4 Oct 2026 01:55:40 +0200 Subject: [PATCH] Theseus: an add-on can limit which sites get its bridge; vault secrets stay first-party - Page-inject policy. An add-on may keep { mode, origins } under the reserved storage key "__pageInjectPolicy"; in "allowed" mode its bridge is injected, and its page messages accepted, only on the listed origins. It is read from the store because the decision is made synchronously at document start and must hold while an on-demand add-on is still dormant. A wallet injected into every page tells every page the user has one. api.features.pageInjectPolicy lets an add-on tell an old host apart. - vault.imports (raw imported seeds and keys, not namespaced per add-on) and vault.lifecycle unlock/setup/lock (the master password, and an unthrottled oracle for it) are for add-ons that ship with Theseus. A community extension with vault-derive could read the wallet's imported keys. Others use vault.requestUnlock, where Theseus draws the prompt. --- addons-host.js | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/addons-host.js b/addons-host.js index 11e93172..cde35c2c 100644 --- a/addons-host.js +++ b/addons-host.js @@ -369,11 +369,20 @@ class AddonHost { } if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); }; + // Taking the master password (unlock is also an unthrottled oracle for + // it), creating the vault and locking it are for add-ons that ship with + // Theseus. Anything else asks the user through requestUnlock, where + // Theseus draws the prompt and the add-on never sees what was typed. + const firstPartyOnly = (what) => { + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { + throw new Error(`vault.lifecycle.${what} is reserved for built-in add-ons — use vault.requestUnlock()`); + } + }; return { status: async () => { requireCap(); return this._vaultLifecycle.status(); }, - unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, - setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, - lock: async () => { requireCap(); return this._vaultLifecycle.lock(manifest.id); }, + unlock: async (pw) => { requireCap(); firstPartyOnly("unlock"); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, + setup: async (pw, seedSource) => { requireCap(); firstPartyOnly("setup"); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, + lock: async () => { requireCap(); firstPartyOnly("lock"); return this._vaultLifecycle.lock(manifest.id); }, }; } @@ -386,6 +395,14 @@ class AddonHost { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); + // The imports store holds raw seeds and private keys and is not + // namespaced per add-on: list() and signer() hand back whatever any + // add-on stored. Until it is, it belongs to built-in add-ons only — + // a community extension with vault-derive could read the wallet's + // imported keys. + if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { + throw new Error("vault.imports is reserved for built-in add-ons"); + } }; return { list: async () => { requireCap(); return this._vaultImports.list(); }, @@ -579,6 +596,9 @@ class AddonHost { const { manifest, folder } = active; const storageFile = path.join(this.dataDir, `${manifest.id}.json`); return { + // What this host can do beyond the documented surface, so an add-on + // can tell "the user switched it off" from "this Theseus is too old". + features: Object.freeze({ pageInjectPolicy: true }), // Metadata the add-on may want to reflect on id: manifest.id, folder, @@ -937,9 +957,27 @@ class AddonHost { // Inject scripts that apply to a tab URL — [{ id, source }]. A waiting // add-on's bridge is injected too (its source is read on first match); the // bridge's first message is what starts the add-on. + // Which pages an add-on's bridge is injected into, beyond its manifest + // patterns. The add-on keeps `{ mode: "all" | "allowed", origins: [...] }` + // under the reserved storage key "__pageInjectPolicy"; in "allowed" mode + // only the listed origins get the bridge. It is read from the store, not + // asked of the add-on, because the decision is made synchronously at + // document start and has to hold while an on-demand add-on is still + // dormant. A wallet that announces itself to every page tells every page + // the user has one; this lets the user say which sites may know. + _injectPolicyAllows(id, url) { + let policy = null; + try { policy = storeFor(path.join(this.dataDir, `${id}.json`), () => {}).get("__pageInjectPolicy", null); } + catch { return true; } + if (!policy || policy.mode !== "allowed") return true; + let origin = ""; + try { const u = new URL(String(url).replace(/^bns:\/\//i, "https://")); origin = `${u.protocol}//${u.host}`; } catch { return false; } + return Array.isArray(policy.origins) && policy.origins.includes(origin); + } injectionsFor(url) { const out = []; for (const { manifest, active, dormant } of this._enabledEntries()) { + if (!this._injectPolicyAllows(manifest.id, url)) continue; if (active) { if (active.inject && urlMatchesAny(url, active.inject.matchers)) out.push({ id: manifest.id, source: active.inject.source }); continue; @@ -957,6 +995,7 @@ class AddonHost { // 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) { + if (!this._injectPolicyAllows(id, url)) return false; const active = this._active.get(id); if (active) return !!(active.inject && urlMatchesAny(url, active.inject.matchers)); const d = this._dormant.get(id);