- 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>
194 lines
7.4 KiB
JavaScript
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 "?"; }
|
|
}
|