Googstr/extension/background.js
Jason Tudisco fcfaec675a Add NIP-46 signer, optional NIP-07 extension, passkey protection, backups
- NIP-46 remote signer in the page (bunker:// and nostrconnect:// pairing)
- Chrome extension (NIP-07) with per-site permission prompts, unlocked by
  the site via externally_connectable; downloadable from the page
- Passkey (WebAuthn PRF) encryption of the Drive key file
- NIP-49 backups: download or save to Drive; reveal nsec
- Fix: oldest key file wins when two devices sign up at once
- Fix: never overwrite an existing kind-0 because a relay was slow
- Fix: EOSE tracking; relays reconnect with backoff; verify event signatures
- One-click re-sign-in (consent only the first time)
- Vendored, pinned nostr-tools; CSP; no third-party scripts but Google's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:33:02 -06:00

172 lines
5.8 KiB
JavaScript

// 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;
});