- 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>
172 lines
5.8 KiB
JavaScript
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;
|
|
});
|