// Goostr signer — background service worker. // // Holds the nostr key in chrome.storage.session (memory only, cleared when the // browser quits) and answers NIP-07 requests from web pages, asking the user // before a site may use it. The key arrives from the Goostr website via // externally_connectable messaging — only origins listed in manifest.json can // send it. import { getPublicKey, finalizeEvent, nip04, nip19, nip44, hexToBytes } from "./vendor/nostr-tools.js"; import { GOOSTR_URL } from "./config.js"; const PROMPT_W = 380; const PROMPT_H = 560; async function getKey() { const { sk } = await chrome.storage.session.get("sk"); return sk ? hexToBytes(sk) : null; } async function getPerms() { const { perms } = await chrome.storage.local.get("perms"); return perms || {}; // origin -> "always" } async function setBadge() { const sk = await getKey(); await chrome.action.setBadgeText({ text: sk ? "" : "off" }); await chrome.action.setBadgeBackgroundColor({ color: "#6d28d9" }); } chrome.runtime.onStartup.addListener(setBadge); chrome.runtime.onInstalled.addListener(setBadge); // ---------- messages from the Goostr website ---------- chrome.runtime.onMessageExternal.addListener((msg, sender, respond) => { (async () => { switch (msg?.type) { case "status": { const sk = await getKey(); return { installed: true, version: chrome.runtime.getManifest().version, pubkey: sk ? getPublicKey(sk) : null }; } case "setKey": { if (!/^[0-9a-f]{64}$/.test(msg.sk || "")) throw new Error("bad key"); await chrome.storage.session.set({ sk: msg.sk, profile: msg.profile || null, from: sender.origin }); await setBadge(); return { ok: true, pubkey: getPublicKey(hexToBytes(msg.sk)) }; } case "lock": await chrome.storage.session.remove(["sk", "profile"]); await setBadge(); return { ok: true }; default: throw new Error("unknown message"); } })().then(respond, (e) => respond({ error: String(e.message || e) })); return true; }); // ---------- permission prompts ---------- const pending = new Map(); // id -> { resolve, windowId } function askUser(request) { return new Promise(async (resolve) => { const id = crypto.randomUUID(); await chrome.storage.session.set({ [`prompt:${id}`]: request }); const win = await chrome.windows.create({ url: `prompt.html?id=${id}`, type: "popup", width: PROMPT_W, height: PROMPT_H, }); pending.set(id, { resolve, windowId: win.id }); }); } function settle(id, decision) { const p = pending.get(id); if (!p) return; pending.delete(id); chrome.storage.session.remove(`prompt:${id}`); p.resolve(decision); } // An open port from prompt.html keeps this worker alive while the user decides. chrome.runtime.onConnect.addListener(() => {}); chrome.windows.onRemoved.addListener((windowId) => { for (const [id, p] of pending) if (p.windowId === windowId) settle(id, "deny"); }); // ---------- NIP-07 ---------- async function nip07(method, params, origin) { let sk = await getKey(); const perms = await getPerms(); if (!sk || perms[origin] !== "always") { const decision = await askUser({ origin, method, params, locked: !sk }); if (decision === "deny") throw new Error("Rejected by user"); if (decision === "always") { const fresh = await getPerms(); fresh[origin] = "always"; await chrome.storage.local.set({ perms: fresh }); } sk = await getKey(); // the prompt may have unlocked us if (!sk) throw new Error("Goostr is locked. Click the Goostr extension icon and unlock."); } switch (method) { case "getPublicKey": return getPublicKey(sk); case "signEvent": { const e = params.event; return finalizeEvent( { kind: e.kind, content: e.content ?? "", tags: e.tags ?? [], created_at: e.created_at ?? Math.floor(Date.now() / 1000) }, sk ); } case "getRelays": return {}; case "nip04.encrypt": return nip04.encrypt(sk, params.pubkey, params.plaintext); case "nip04.decrypt": return nip04.decrypt(sk, params.pubkey, params.ciphertext); case "nip44.encrypt": return nip44.encrypt(params.plaintext, nip44.getConversationKey(sk, params.pubkey)); case "nip44.decrypt": return nip44.decrypt(params.ciphertext, nip44.getConversationKey(sk, params.pubkey)); default: throw new Error(`Unsupported method ${method}`); } } // ---------- messages from our own pages and content scripts ---------- chrome.runtime.onMessage.addListener((msg, sender, respond) => { if (sender.id !== chrome.runtime.id) return; (async () => { switch (msg?.type) { case "nip07": // sender.origin comes from the browser, not the page, so it can't be spoofed. return { result: await nip07(msg.method, msg.params || {}, sender.origin) }; case "prompt-decision": settle(msg.id, msg.decision); return { ok: true }; case "popup-state": { const sk = await getKey(); const { profile } = await chrome.storage.session.get("profile"); return { npub: sk ? nip19.npubEncode(getPublicKey(sk)) : null, profile: profile || null, perms: await getPerms(), goostrUrl: GOOSTR_URL, }; } case "lock": await chrome.storage.session.remove(["sk", "profile"]); await setBadge(); return { ok: true }; case "revoke": { const perms = await getPerms(); delete perms[msg.origin]; await chrome.storage.local.set({ perms }); return { ok: true }; } case "open-goostr": await chrome.tabs.create({ url: `${GOOSTR_URL}#extension-unlock` }); return { ok: true }; default: throw new Error("unknown message"); } })().then(respond, (e) => respond({ error: String(e.message || e) })); return true; });