diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index b230381..6df0d83 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -276,15 +276,19 @@ de-duplicated per author+`d` identifier, keeping the latest. never print (hidden in the `@media print` block alongside the other app chrome). - **The board is gated behind a connected Nostr identity.** On load it shows a - login gate (`#board-gate`) instead of the table, with three ways to connect: - a **NIP-07** extension (`window.nostr.getPublicKey()`), **pasting an `nsec`** - (decoded in-page to derive the pubkey, then discarded — the secret is never - stored or transmitted), or **creating a new key** (reuses this device's anon - key in `voxvera_nostr_anon_secret_hex` if present, else generates one, and - reveals the `nsec` with a save-it warning before continuing). On success it - reveals the table and surfaces the connected npub (`#board-identity`) with a - Disconnect control. The board only ever needs the *pubkey* (it reads, never - signs), so the connected pubkey is remembered in `localStorage` + login gate (`#board-gate`) instead of the table, with four ways to connect: + a **NIP-07** extension (`window.nostr.getPublicKey()`), a **NIP-46 remote + signer** (paste a `bunker://` link; same hand-rolled client as the editor — + it establishes the viewer pubkey via `get_public_key` and keeps the connection + so `getBoardSigner` can sign reports/deletes remotely this session, then falls + back to read-only after a reload), **pasting an `nsec`** (decoded in-page to + derive the pubkey, then discarded — the secret is never stored or transmitted), + or **creating a new key** (reuses this device's anon key in + `voxvera_nostr_anon_secret_hex` if present, else generates one, and reveals the + `nsec` with a save-it warning before continuing). On success it reveals the + table and surfaces the connected npub (`#board-identity`) with a Disconnect + control. The board only ever needs the *pubkey* to view (it reads to filter), + so the connected pubkey is remembered in `localStorage` (`voxvera_connected_pubkey`, shared with the main client) and a return visit reconnects from it directly — no prompt or re-entry, any method. This is **not access control** — the events are public on the relays regardless — it diff --git a/docs/roadmap.md b/docs/roadmap.md index fc5d94b..70978d5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -198,15 +198,19 @@ board can show them flyers from people they trust. riskier than a NIP-07 extension that keeps the key isolated; the input is a password field, the value is cleared after use, and nothing is transmitted. The **NIP-46 remote signer** (below) is now the safer mobile path. -- **NIP-46 remote signer (shipped).** The editor can connect a remote signer +- **NIP-46 remote signer (shipped — editor *and* board).** Both the editor's + publishing identity and the board's login gate can connect a remote signer (bunker) by pasting a `bunker://` link: the user's secret stays in their signer app (nsec.app, Amber, …) and the browser only holds an ephemeral local key. The client is hand-rolled over a raw WebSocket using the vendored NIP-44 (with a - NIP-04 decrypt fallback) — the bundle ships no nip46 module. Connections are - **session-only** (the bunker token is never written to disk, so a reload returns - to anonymous). Covered by the cross-engine e2e suite (full connect → - get_public_key → sign_event → publish round-trip against a fake signer doing - real NIP-44 crypto). + NIP-04 decrypt fallback) — the bundle ships no nip46 module. On the board it + establishes the viewer pubkey and keeps the connection so reports/deletes are + signed remotely (read-only after a reload). Connections are **session-only** + (the bunker token is never written to disk, so a reload returns to anonymous / + read-only). Covered by the cross-engine e2e suite (editor: connect → + get_public_key → sign_event → publish; board: connect → reveal → report + signed through the remote signer — both against a fake signer doing real + NIP-44 crypto). - **Editor identity (shipped).** The editor signs under anon / NIP-07 / imported nsec / remote signer. A remembered nsec is PIN-encrypted at rest (PBKDF2 600k → AES-GCM); plaintext is never stored. **Known weakness:** a short numeric PIN is diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 954ff5b..e64bef7 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -1176,6 +1176,86 @@ test.describe("VoxVera static client", () => { expect(rep.content).toBe("clearly spam"); }); + test("board can connect via a NIP-46 remote signer and report through it", async ({ page }) => { + const AUTHOR = "c".repeat(64); + const FLYER_ID = "f".repeat(64); + await page.addInitScript((cfg) => { + function hexToBytes(hex) { + const b = new Uint8Array(hex.length / 2); + for (let i = 0; i < b.length; i += 1) b[i] = parseInt(hex.substr(i * 2, 2), 16); + return b; + } + window.__published = []; + const flyer = { + id: cfg.flyerId, kind: 30078, pubkey: cfg.author, created_at: 2000, + tags: [["d", "voxvera:bad"], ["t", "voxvera"], ["t", "flyer"], ["language", "en"]], + content: JSON.stringify({ type: "voxvera_flyer", version: 1, lang: "en", title: "Stranger Flyer", url: "https://example.com/x" }) + }; + class FakeWS { + constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); } + send(data) { + let m; try { m = JSON.parse(data); } catch (_) { return; } + if (m[0] === "REQ") { + this.sub = m[1]; + const f = m[2] || {}; + const serveFlyer = f.kinds && f.kinds.indexOf(30078) !== -1; + setTimeout(() => { + if (serveFlyer) this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", this.sub, flyer]) }); + this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", this.sub]) }); + }, 1); + return; + } + if (m[0] !== "EVENT") return; + const ev = m[1]; + const NT = window.NostrTools; + if (ev.kind === 24133) { + const skBytes = hexToBytes(window.__nip46SkHex); + const ck = NT.nip44.getConversationKey(skBytes, ev.pubkey); + let req; try { req = JSON.parse(NT.nip44.decrypt(ev.content, ck)); } catch (_) { return; } + let result = ""; + if (req.method === "connect") result = "ack"; + else if (req.method === "get_public_key") result = window.__nip46Pubkey; + else if (req.method === "sign_event") result = JSON.stringify(NT.finalizeEvent(JSON.parse(req.params[0]), skBytes)); + const content = NT.nip44.encrypt(JSON.stringify({ id: req.id, result }), ck); + const resp = NT.finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", ev.pubkey]], content }, skBytes); + const sub = this.sub; + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, resp]) }), 1); + return; + } + window.__published.push(ev); + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", ev.id, true, ""]) }), 1); + } + close() {} + } + window.WebSocket = FakeWS; + }, { author: AUTHOR, flyerId: FLYER_ID }); + await page.goto("/board.html"); + const signerPubkey = await page.evaluate(() => { + const sk = window.NostrTools.generateSecretKey(); + window.__nip46SkHex = Array.from(sk).map((b) => b.toString(16).padStart(2, "0")).join(""); + window.__nip46Pubkey = window.NostrTools.getPublicKey(sk); + return window.__nip46Pubkey; + }); + // Connect the board via the bunker link. + await page.locator("#board-connect-nip46-toggle").click(); + await page.locator("#board-nip46-input").fill(`bunker://${signerPubkey}?relay=wss://relay.damus.io&secret=x`); + await page.locator("#board-nip46-submit").click(); + // The board reveals as the remote-signer identity and shows the flyer. + await expect(page.locator("#board-content")).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(signerPubkey); + const theirRow = page.locator("#board-rows tr", { hasText: "Stranger Flyer" }); + await expect(theirRow).toBeVisible(); + // Reporting signs through the remote signer (sign_event round-trip). + await theirRow.locator(".board-menu-btn").click({ force: true }); + await theirRow.locator('.board-menu-item[data-act="report"]').click({ force: true }); + await page.locator("#board-report-confirm").click({ force: true }); + await expect(page.locator("#board-rows")).not.toContainText("Stranger Flyer"); + const reports = await page.evaluate(() => window.__published.filter((e) => e.kind === 1984)); + expect(reports.length).toBeGreaterThan(0); + expect(reports[0].pubkey).toBe(signerPubkey); + expect(reports[0].tags.some((tg) => tg[0] === "p" && tg[1] === AUTHOR)).toBe(true); + }); + test("safety page renders all sections, localizes, and is linked from editor + board", async ({ page }) => { const errors = trackErrors(page); await page.goto("/safety.html"); diff --git a/site/board.html b/site/board.html index 36cb189..764a9d8 100644 --- a/site/board.html +++ b/site/board.html @@ -45,9 +45,19 @@

Connect a Nostr identity to view the bulletin board.

+
+ +