Googstr/public/bunker.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

194 lines
7.4 KiB
JavaScript

// 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://<user pubkey>.
//
// 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 "?"; }
}