- 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>
91 lines
3.9 KiB
JavaScript
91 lines
3.9 KiB
JavaScript
// Self-check for the NIP-46 signer: node test/bunker.test.mjs
|
|
// Plays a client app against createBunker() through an in-memory "relay".
|
|
import assert from "node:assert/strict";
|
|
import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, nip44 } from "../public/vendor/nostr-tools.js";
|
|
|
|
const memStore = () => { const m = new Map(); return { getItem: (k) => m.get(k) ?? null, setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }; };
|
|
globalThis.localStorage = memStore();
|
|
globalThis.sessionStorage = memStore();
|
|
globalThis.addEventListener = () => {};
|
|
globalThis.document = { addEventListener() {} };
|
|
|
|
const { makeSigner } = await import("../public/goostr.js");
|
|
const { createBunker } = await import("../public/bunker.js");
|
|
|
|
// In-memory pool: subscribe() captures the handler, publish() collects replies.
|
|
const published = [];
|
|
let deliver = null;
|
|
const pool = {
|
|
subscribe: (_f, onEvent) => { deliver = onEvent; return () => { deliver = null; }; },
|
|
publish: (ev) => published.push(ev),
|
|
addRelay() {},
|
|
};
|
|
|
|
const userSk = generateSecretKey();
|
|
const signer = makeSigner(userSk);
|
|
const bunker = createBunker({ signer, pool, relays: ["wss://relay.example"] });
|
|
bunker.start();
|
|
|
|
function client() {
|
|
const sk = generateSecretKey();
|
|
const pk = getPublicKey(sk);
|
|
const ck = nip44.getConversationKey(sk, signer.pubkey);
|
|
return {
|
|
pk,
|
|
async call(method, params) {
|
|
const id = Math.random().toString(36).slice(2);
|
|
const req = finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", signer.pubkey]],
|
|
content: nip44.encrypt(JSON.stringify({ id, method, params }), ck) }, sk);
|
|
const before = published.length;
|
|
await deliver(req);
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
const res = published.slice(before).find((e) => e.tags.some((t) => t[0] === "p" && t[1] === pk));
|
|
assert.ok(res, `no reply to ${method}`);
|
|
assert.ok(verifyEvent(res));
|
|
assert.equal(res.pubkey, signer.pubkey);
|
|
const body = JSON.parse(nip44.decrypt(res.content, ck));
|
|
assert.equal(body.id, id);
|
|
return body;
|
|
},
|
|
};
|
|
}
|
|
|
|
// bunker:// pairing
|
|
const url = new URL(bunker.newBunkerUrl().replace("bunker://", "https://"));
|
|
assert.equal(url.hostname, signer.pubkey);
|
|
const secret = url.searchParams.get("secret");
|
|
const app = client();
|
|
|
|
assert.match((await app.call("get_public_key", [])).error, /unauthorized/, "unpaired app must be refused");
|
|
assert.match((await app.call("connect", [signer.pubkey, "wrong"])).error, /unauthorized/);
|
|
assert.equal((await app.call("connect", [signer.pubkey, secret])).result, "ack");
|
|
assert.equal((await app.call("get_public_key", [])).result, signer.pubkey);
|
|
assert.equal((await app.call("ping", [])).result, "pong");
|
|
|
|
const signed = JSON.parse((await app.call("sign_event", [JSON.stringify({ kind: 1, content: "hi", tags: [], created_at: 1 })])).result);
|
|
assert.ok(verifyEvent(signed));
|
|
assert.equal(signed.pubkey, signer.pubkey);
|
|
|
|
const other = client();
|
|
const ct = (await app.call("nip44_encrypt", [other.pk, "secret msg"])).result;
|
|
assert.equal((await app.call("nip44_decrypt", [other.pk, ct])).result, "secret msg");
|
|
|
|
// the secret is single-use
|
|
const thief = client();
|
|
assert.match((await thief.call("connect", [signer.pubkey, secret])).error, /unauthorized/);
|
|
|
|
// revoke
|
|
bunker.revoke(app.pk);
|
|
assert.match((await app.call("get_public_key", [])).error, /unauthorized/);
|
|
|
|
// nostrconnect:// pairing: we must answer with the app's secret
|
|
const ncSk = generateSecretKey();
|
|
const ncPk = getPublicKey(ncSk);
|
|
const before = published.length;
|
|
await bunker.acceptNostrConnect(`nostrconnect://${ncPk}?relay=wss%3A%2F%2Frelay.example&secret=s3cr3t&name=Test%20App`);
|
|
const ack = published.slice(before)[0];
|
|
assert.equal(JSON.parse(nip44.decrypt(ack.content, nip44.getConversationKey(ncSk, signer.pubkey))).result, "s3cr3t");
|
|
assert.equal(bunker.clients[ncPk].name, "Test App");
|
|
|
|
console.log("bunker: all checks passed");
|