Aegis: a plain Connect now lasts for the session

Connecting without ticking "Always allow" stored nothing, so the
site's very next call (personal_sign, signTransaction, ...) failed with
"not connected". Plain Connect now grants the origin in memory until
Theseus restarts; "Always allow" still persists. Every bridge (BCH,
Tron, EVM, Solana) checks grants the same way, revoke clears both, and
the connected-sites list shows EVM/Solana grants and which ones are
session-only.
This commit is contained in:
Local Dev 2026-10-03 22:49:15 +02:00
parent da56187b39
commit 4c3d27f1b3
2 changed files with 91 additions and 48 deletions

View file

@ -2292,13 +2292,12 @@ function registerPanelMessages(api) {
};
});
api.onMessage("permissions", (_p, m) => { fromPanel(m); return permissions(api); });
api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); });
api.onMessage("revoke", (p, m) => {
fromPanel(m);
const perms = permissions(api);
delete perms[String(p && p.origin || "")];
api.storage.set("permissions", perms);
return perms;
revokeOrigin(api, String(p && p.origin || ""));
emitState();
return grantsForPanel(api);
});
// ---- security: quick-access PIN + policy flags ------------------------
@ -2602,6 +2601,75 @@ function describePlan(plan) {
const BCH_ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH
const pendingByOrigin = new Set();
function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; }
// ---- grants: session + persisted ------------------------------------------
// A plain "Connect" grants the origin for this browser session (memory only);
// ticking "Always allow" persists it under api.storage "permissions". Both
// look identical to the handlers via grantFor(). Scopes: "bch" (the
// window.bitcoincash bridge keeps its flat readAddress), "eth", "sol", "trx".
// ETH grants also carry the origin's chainId — chain is per origin, never a
// global the sidebar or another site can flip under a connected dapp.
// Before this, a plain "Connect" stored nothing, so the very next call from
// the site failed with "not connected".
const sessionGrants = new Map(); // origin -> { readAddress?, eth?, sol?, trx? }
function grantFor(api, origin, scope) {
const persisted = permissions(api)[origin] || {};
const session = sessionGrants.get(origin) || {};
if (scope === "bch") {
if (persisted.readAddress) return { readAddress: true, persisted: true };
if (session.readAddress) return { readAddress: true, persisted: false };
return null;
}
const p = persisted[scope], s = session[scope];
if (p && p.readAddress) return { ...p, persisted: true };
if (s && s.readAddress) return { ...s, persisted: false };
return null;
}
function setGrant(api, origin, scope, value, persist) {
const wrap = scope === "bch" ? value : { [scope]: value };
if (persist) {
const perms = permissions(api);
perms[origin] = { ...(perms[origin] || {}), ...wrap };
api.storage.set("permissions", perms);
} else {
sessionGrants.set(origin, { ...(sessionGrants.get(origin) || {}), ...wrap });
}
emitState();
}
// Patch fields (e.g. chainId) on whichever grant the origin currently holds.
function patchGrant(api, origin, scope, patch) {
const g = grantFor(api, origin, scope);
if (!g) return false;
const { persisted, ...rest } = g;
setGrant(api, origin, scope, { ...rest, ...patch }, persisted);
return true;
}
// An unconnected site that asked for a chain (addChain / switch before
// connect) gets it remembered for its eventual eth_requestAccounts — no
// address is disclosed by this.
function rememberPendingChain(origin, chainId) {
const cur = sessionGrants.get(origin) || {};
sessionGrants.set(origin, { ...cur, eth: { ...(cur.eth || {}), chainId } });
}
function revokeOrigin(api, origin) {
const perms = permissions(api);
delete perms[origin];
api.storage.set("permissions", perms);
sessionGrants.delete(origin);
}
// Panel view: persisted grants plus session-only ones. An origin with no
// persisted entry is flagged `session`; one with both gets the session
// scopes merged in under the persisted ones.
function grantsForPanel(api) {
const out = JSON.parse(JSON.stringify(permissions(api)));
for (const [origin, g] of sessionGrants) {
const hasGrant = g.readAddress || g.eth?.readAddress || g.sol?.readAddress || g.trx?.readAddress;
if (!hasGrant) continue;
out[origin] = out[origin] ? { ...g, ...out[origin] } : { ...g, session: true };
}
return out;
}
async function withOriginLock(origin, fn) {
if (pendingByOrigin.has(origin)) throw new Error("a wallet request from this site is already waiting for approval");
pendingByOrigin.add(origin);
@ -2643,8 +2711,7 @@ function registerPageMessages(api) {
const origin = fromPage(m);
if (!isDappOrigin(origin)) throw new Error("this page cannot use the wallet bridge");
const rt = legacyBchRuntime();
const perms = permissions(api);
if (perms[origin] && perms[origin].readAddress) return rt.adapter.current().address;
if (grantFor(api, origin, "bch")) return rt.adapter.current().address;
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Share your Bitcoin Cash address?",
@ -2655,7 +2722,7 @@ function registerPageMessages(api) {
checkbox: { id: "always", label: "Always allow this site to see my address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") { perms[origin] = { ...(perms[origin] || {}), readAddress: true }; api.storage.set("permissions", perms); emitState(); }
setGrant(api, origin, "bch", { readAddress: true }, pick === "allow+always");
return rt.adapter.current().address;
});
});
@ -2803,10 +2870,8 @@ function registerPageMessages(api) {
api.onMessage("trx.requestAccounts", async (_p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
const alreadyOK = perms[origin] && perms[origin].trx && perms[origin].trx.readAddress;
const snap = rt.adapter.snapshot();
if (alreadyOK) return { code: 200, address: snap.address, network: snap.network };
if (grantFor(api, origin, "trx")) return { code: 200, address: snap.address, network: snap.network };
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Connect this site to your Tron wallet?",
@ -2821,18 +2886,13 @@ function registerPageMessages(api) {
checkbox: { id: "always", label: "Always allow this site to see this address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") {
perms[origin] = { ...(perms[origin] || {}), trx: { readAddress: true, network: snap.network } };
api.storage.set("permissions", perms);
emitState();
}
setGrant(api, origin, "trx", { readAddress: true, network: snap.network }, pick === "allow+always");
return { code: 200, address: snap.address, network: snap.network };
});
});
api.onMessage("trx.getAccount", (_p, m) => {
const origin = fromPage(m);
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
const rt = activeTronRuntime();
const snap = rt.adapter.snapshot();
return { address: snap.address, network: snap.network };
@ -2843,8 +2903,7 @@ function registerPageMessages(api) {
api.onMessage("trx.signTransaction", async (p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
const tx = p && p.transaction;
if (!tx || typeof tx !== "object" || !tx.raw_data_hex || !tx.raw_data) throw new Error("bad transaction");
return withOriginLock(origin, async () => {
@ -2876,8 +2935,7 @@ function registerPageMessages(api) {
api.onMessage("trx.sendRawTransaction", async (p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
const signedTx = p && p.transaction;
if (!signedTx || !signedTx.raw_data_hex || !Array.isArray(signedTx.signature)) throw new Error("bad signed tx");
// No approval here — broadcasting a *signed* tx does not add any risk
@ -2888,8 +2946,7 @@ function registerPageMessages(api) {
api.onMessage("trx.signMessageV2", async (p, m) => {
const origin = fromPage(m);
const rt = activeTronRuntime();
const perms = permissions(api);
if (!(perms[origin] && perms[origin].trx && perms[origin].trx.readAddress)) throw new Error("not connected — call tron_requestAccounts first");
if (!grantFor(api, origin, "trx")) throw new Error("not connected — call tron_requestAccounts first");
const message = String(p && p.message != null ? p.message : "");
if (message.length > 4096) throw new Error("message too long");
return withOriginLock(origin, async () => {
@ -2921,20 +2978,14 @@ function registerPageMessages(api) {
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return rt;
throw new Error("no Ethereum wallet available — add one in the Aegis sidebar");
}
function ethConnectedFor(origin) {
const p = permissions(api)[origin];
return !!(p && p.eth && p.eth.readAddress);
}
function ethConnectedFor(origin) { return !!grantFor(api, origin, "eth"); }
api.onMessage("eth.requestAccounts", async (_p, m) => {
const origin = fromPage(m);
const rt = activeEthRuntime();
const snap = rt.adapter.snapshot();
const chainIdHex = "0x" + Number(snap.chainId).toString(16);
const networkVersion = String(snap.chainId);
const perms = permissions(api);
if (perms[origin] && perms[origin].eth && perms[origin].eth.readAddress) {
return { address: snap.address, chainIdHex, networkVersion };
}
if (ethConnectedFor(origin)) return { address: snap.address, chainIdHex, networkVersion };
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Connect this site to your Ethereum wallet?",
@ -2949,11 +3000,7 @@ function registerPageMessages(api) {
checkbox: { id: "always", label: "Always allow this site to see this address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") {
perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId: snap.chainId } };
api.storage.set("permissions", perms);
emitState();
}
setGrant(api, origin, "eth", { readAddress: true, chainId: snap.chainId }, pick === "allow+always");
return { address: snap.address, chainIdHex, networkVersion };
});
});
@ -3183,10 +3230,7 @@ function registerPageMessages(api) {
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return rt;
throw new Error("no Solana wallet available — add one in the Aegis sidebar");
}
function solConnectedFor(origin) {
const p = permissions(api)[origin];
return !!(p && p.sol && p.sol.readAddress);
}
function solConnectedFor(origin) { return !!grantFor(api, origin, "sol"); }
api.onMessage("sol.connect", async (_p, m) => {
const origin = fromPage(m);
const rt = activeSolRuntime();
@ -3206,12 +3250,7 @@ function registerPageMessages(api) {
checkbox: { id: "always", label: "Always allow this site to see this address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") {
const perms = permissions(api);
perms[origin] = { ...(perms[origin] || {}), sol: { readAddress: true, network: snap.network } };
api.storage.set("permissions", perms);
emitState();
}
setGrant(api, origin, "sol", { readAddress: true, network: snap.network }, pick === "allow+always");
return { address: snap.address, network: snap.network };
});
});

View file

@ -5289,7 +5289,7 @@ async function renderSites() {
try { perms = await S.invoke("permissions"); } catch {}
const origins = Object.keys(perms).filter((o) => {
const p = perms[o];
return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress));
return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress) || (p.eth && p.eth.readAddress) || (p.sol && p.sol.readAddress));
});
const el = $("sites");
if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; }
@ -5298,6 +5298,10 @@ async function renderSites() {
if (p.readAddress) what.push("BCH address");
if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`);
if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address");
if (p.eth && p.eth.readAddress) what.push("EVM address" + (p.eth.chainId ? ` (chain ${p.eth.chainId})` : ""));
if (p.sol && p.sol.readAddress) what.push("Solana address");
// A plain "Connect" lasts until Theseus restarts; only "Always allow" is stored.
if (p.session) what.push("this session only");
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`;
}).join("");
el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => {