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.
This commit is contained in:
Local Dev 2026-10-04 01:55:40 +02:00
parent 884f3948b8
commit 80fe82abee

View file

@ -369,11 +369,20 @@ class AddonHost {
} }
if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); 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 { return {
status: async () => { requireCap(); return this._vaultLifecycle.status(); }, status: async () => { requireCap(); return this._vaultLifecycle.status(); },
unlock: async (pw) => { requireCap(); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, unlock: async (pw) => { requireCap(); firstPartyOnly("unlock"); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); },
setup: async (pw, seedSource) => { requireCap(); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, setup: async (pw, seedSource) => { requireCap(); firstPartyOnly("setup"); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); },
lock: async () => { requireCap(); return this._vaultLifecycle.lock(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`); 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)"); 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 { return {
list: async () => { requireCap(); return this._vaultImports.list(); }, list: async () => { requireCap(); return this._vaultImports.list(); },
@ -579,6 +596,9 @@ class AddonHost {
const { manifest, folder } = active; const { manifest, folder } = active;
const storageFile = path.join(this.dataDir, `${manifest.id}.json`); const storageFile = path.join(this.dataDir, `${manifest.id}.json`);
return { 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 // Metadata the add-on may want to reflect on
id: manifest.id, id: manifest.id,
folder, folder,
@ -937,9 +957,27 @@ class AddonHost {
// Inject scripts that apply to a tab URL — [{ id, source }]. A waiting // 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 // 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. // 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) { injectionsFor(url) {
const out = []; const out = [];
for (const { manifest, active, dormant } of this._enabledEntries()) { for (const { manifest, active, dormant } of this._enabledEntries()) {
if (!this._injectPolicyAllows(manifest.id, url)) continue;
if (active) { if (active) {
if (active.inject && urlMatchesAny(url, active.inject.matchers)) out.push({ id: manifest.id, source: active.inject.source }); if (active.inject && urlMatchesAny(url, active.inject.matchers)) out.push({ id: manifest.id, source: active.inject.source });
continue; continue;
@ -957,6 +995,7 @@ class AddonHost {
// Does this add-on's page-inject declaration cover the URL? Used to gate // 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. // page → add-on IPC so a non-matching page can't spoof a matching one.
pageAllowed(id, url) { pageAllowed(id, url) {
if (!this._injectPolicyAllows(id, url)) return false;
const active = this._active.get(id); const active = this._active.get(id);
if (active) return !!(active.inject && urlMatchesAny(url, active.inject.matchers)); if (active) return !!(active.inject && urlMatchesAny(url, active.inject.matchers));
const d = this._dormant.get(id); const d = this._dormant.get(id);