// NIP-46 remote signer ("bunker") running inside this tab. // // Other nostr apps talk to us over relays with kind-24133 events, encrypted // with NIP-44 (NIP-04 accepted from older clients). We use the user's own key // as the remote-signer key, so bunker://. // // Pairing: // * bunker:// link — we mint a one-time secret; the first client to send // `connect` with it becomes authorized. // * nostrconnect:// link — the app shows it; we authorize that client and // reply to `connect` with its secret, per the spec. // Authorized clients are remembered per identity in localStorage (only their // pubkeys and names; no secrets). Pending bunker secrets live in // sessionStorage and die with the tab. // // ponytail: authorized clients get every method without per-request prompts, // like NIP-07 "always allow". Add per-kind approval if users ask for it. const KIND = 24133; const LOG_MAX = 30; const load = (store, key, fallback) => { try { return JSON.parse(store.getItem(key)) ?? fallback; } catch { return fallback; } }; const save = (store, key, value) => { try { store.setItem(key, JSON.stringify(value)); } catch {} }; const randomHex = (n) => [...crypto.getRandomValues(new Uint8Array(n))].map((b) => b.toString(16).padStart(2, "0")).join(""); export function createBunker({ signer, pool, relays, onChange = () => {} }) { const clientsKey = `goostr.bunker.clients.${signer.pubkey}`; const secretsKey = `goostr.bunker.secrets.${signer.pubkey}`; const clients = load(localStorage, clientsKey, {}); // pubkey -> { name, addedAt, lastSeen } const log = []; const handled = new Set(); let unsub = null; const persist = () => { save(localStorage, clientsKey, clients); onChange(); }; const note = (entry) => { log.unshift({ t: Date.now(), ...entry }); if (log.length > LOG_MAX) log.pop(); onChange(); }; async function reply(clientPk, scheme, payload) { const plain = JSON.stringify(payload); const content = scheme === "nip04" ? await signer.nip04.encrypt(clientPk, plain) : await signer.nip44.encrypt(clientPk, plain); pool.publish(signer.sign({ kind: KIND, content, tags: [["p", clientPk]] })); } async function run(method, params, clientPk) { switch (method) { case "connect": { if (clients[clientPk]) return "ack"; const secret = params[1]; const pending = load(sessionStorage, secretsKey, []); if (!secret || !pending.includes(secret)) throw new Error("unauthorized: invalid or already used connection secret"); save(sessionStorage, secretsKey, pending.filter((s) => s !== secret)); clients[clientPk] = { name: `App ${clientPk.slice(0, 8)}`, addedAt: Date.now(), lastSeen: Date.now() }; persist(); return "ack"; } case "ping": return "pong"; case "get_public_key": return signer.pubkey; case "sign_event": { const ev = JSON.parse(params[0]); if (!Number.isInteger(ev.kind)) throw new Error("sign_event: missing kind"); return JSON.stringify(signer.sign(ev)); } case "nip04_encrypt": return signer.nip04.encrypt(params[0], params[1]); case "nip04_decrypt": return signer.nip04.decrypt(params[0], params[1]); case "nip44_encrypt": return signer.nip44.encrypt(params[0], params[1]); case "nip44_decrypt": return signer.nip44.decrypt(params[0], params[1]); case "logout": delete clients[clientPk]; persist(); return "ack"; default: throw new Error(`unsupported method: ${method}`); } } async function handle(ev) { if (handled.has(ev.id)) return; handled.add(ev.id); if (ev.kind !== KIND) return; // signature already checked by the pool const clientPk = ev.pubkey; const scheme = ev.content.includes("?iv=") ? "nip04" : "nip44"; let req; try { const plain = scheme === "nip04" ? await signer.nip04.decrypt(clientPk, ev.content) : await signer.nip44.decrypt(clientPk, ev.content); req = JSON.parse(plain); } catch { return; // not for us, or garbage } if (!req?.id || !req.method) return; const params = Array.isArray(req.params) ? req.params : []; const known = !!clients[clientPk]; if (!known && req.method !== "connect") { // Unknown apps get an error rather than silence, so they can tell the // user to pair first. Nothing is signed or decrypted for them. await reply(clientPk, scheme, { id: req.id, error: "unauthorized: pair this app with Goostr first" }); return; } const who = clients[clientPk]?.name || `App ${clientPk.slice(0, 8)}`; try { const result = await run(req.method, params, clientPk); await reply(clientPk, scheme, { id: req.id, result }); if (clients[clientPk]) { clients[clientPk].lastSeen = Date.now(); persist(); } const detail = req.method === "sign_event" ? `kind ${safeKind(params[0])}` : ""; note({ who, method: req.method, detail, ok: true }); } catch (err) { await reply(clientPk, scheme, { id: req.id, error: String(err.message || err) }); note({ who, method: req.method, detail: String(err.message || err), ok: false }); } } return { get running() { return !!unsub; }, get clients() { return clients; }, get log() { return log; }, start() { if (unsub) return; unsub = pool.subscribe( [{ kinds: [KIND], "#p": [signer.pubkey], since: Math.floor(Date.now() / 1000) - 60 }], (ev) => { handle(ev).catch((e) => console.warn("[bunker]", e)); } ); onChange(); }, stop() { unsub?.(); unsub = null; onChange(); }, // One-time bunker:// link for pasting into another app. newBunkerUrl() { const secret = randomHex(16); save(sessionStorage, secretsKey, [...load(sessionStorage, secretsKey, []), secret]); const qs = relays.map((r) => `relay=${encodeURIComponent(r)}`).join("&"); return `bunker://${signer.pubkey}?${qs}&secret=${secret}`; }, // Accept a nostrconnect:// link shown by another app. async acceptNostrConnect(uri) { const u = new URL(uri.trim()); if (u.protocol !== "nostrconnect:") throw new Error("That isn't a nostrconnect:// link."); const clientPk = (u.hostname || u.pathname.replace(/^\/+/, "")).toLowerCase(); if (!/^[0-9a-f]{64}$/.test(clientPk)) throw new Error("The link has no valid app public key."); const secret = u.searchParams.get("secret"); if (!secret) throw new Error("The link has no secret; the app may use an old NIP-46 version."); const linkRelays = u.searchParams.getAll("relay"); if (!linkRelays.length) throw new Error("The link names no relays."); linkRelays.forEach((r) => pool.addRelay(r)); clients[clientPk] = { name: u.searchParams.get("name") || `App ${clientPk.slice(0, 8)}`, addedAt: Date.now(), lastSeen: Date.now(), }; persist(); this.start(); await reply(clientPk, "nip44", { id: randomHex(8), result: secret }); note({ who: clients[clientPk].name, method: "connect", detail: "via nostrconnect link", ok: true }); }, rename(clientPk, name) { if (!clients[clientPk]) return; clients[clientPk].name = name; persist(); }, revoke(clientPk) { delete clients[clientPk]; persist(); }, }; } function safeKind(json) { try { return JSON.parse(json).kind; } catch { return "?"; } }