diff --git a/README.md b/README.md index 098822f..cb18d89 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. ## Features - **Author and publish.** Fill in the flyer fields and publish them as a Nostr `30078` parameterized replaceable event. Republishing with the same flyer name replaces the previous version. -- **Anonymous by default, your identity if you want it.** The editor signs flyers under one of four identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), an **imported `nsec`**, or a **NIP-46 remote signer** (paste a `bunker://` link — your secret stays in your signer app, the browser holds only an ephemeral key; session-only). An imported key signs in-memory for the session; tick "Remember on this device" to keep it, encrypted with a PIN (PBKDF2 → AES-GCM) — the plaintext secret is never stored. A short PIN is a convenience lock, not protection against a targeted attacker (see the [spec](docs/nostr-static-client-spec.md#publishing-identity)); the extension and remote-signer paths are stronger. +- **Anonymous by default, your identity if you want it.** The editor signs flyers under one of four identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), an **imported `nsec`**, or a **NIP-46 remote signer** (paste a `bunker://` link or scan a `nostrconnect://` QR with your signer app — your secret stays in the app, the browser holds only an ephemeral key; session-only). An imported key signs in-memory for the session; tick "Remember on this device" to keep it, encrypted with a PIN (PBKDF2 → AES-GCM) — the plaintext secret is never stored. A short PIN is a convenience lock, not protection against a targeted attacker (see the [spec](docs/nostr-static-client-spec.md#publishing-identity)); the extension and remote-signer paths are stronger. - **Publish confirmation.** After signing and publishing, a modal shows the new flyer's URL to copy, so authors know they're done and where to find it (localized, like everything else). - **Edit, re-publish, and delete your flyers.** Because flyers are replaceable events, the author can edit one in place (same key + Flyer Name keeps the same URL), re-publish it as a keep-alive so relays don't age it out, or delete it. Delete publishes a replaceable **tombstone** (so VoxVera hides it even where relays ignore deletions) **and** a NIP-09 deletion request — best-effort, author-only. - **Import / export a flyer design.** Save the current flyer as a portable `.json` file and load it back later — or hand it to someone else. The design file holds the content and relay list but **nothing identity-bound** (no npub, address, or signature; those are generated at publish), so a recipient can import it and publish under their own key. Importing asks for confirmation before replacing what's in the editor. diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 6df0d83..139e994 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -143,15 +143,22 @@ identity controls (`activeIdentity` / `signActiveEvent` in `nostr-client.js`): memory** for the session. If the user ticks "Remember on this device", the secret is encrypted with a PIN and stored as `voxvera_identity_nsec_enc`; the plaintext secret is never written to storage. -- **Remote signer (NIP-46 / bunker).** The user pastes a `bunker://` link from a - signer app (nsec.app, Amber, …). The client mints an **ephemeral** local key, - connects to the bunker relay over a raw WebSocket, and exchanges encrypted - kind-`24133` JSON-RPC requests (`connect` → `get_public_key` → `sign_event`). - The user's real secret never reaches the browser. Requests are encrypted with - **NIP-44** (with a NIP-04 decrypt fallback for older signers); the vendored - nostr-tools bundle has no nip46 module, so this client is hand-rolled. The - connection is **session-only** — the bunker token is never persisted, so a - reload returns to anonymous. +- **Remote signer (NIP-46).** The user's real secret never reaches the browser; + the client mints an **ephemeral** local key and exchanges encrypted + kind-`24133` JSON-RPC requests (`connect` → `get_public_key` → `sign_event`) + over a raw WebSocket. Two connect paths are offered: + - **`bunker://` (paste).** The user pastes a link the signer app generated; + the client connects out to that bunker relay. + - **`nostrconnect://` (QR).** The client generates the link itself, renders it + as a QR (`makeQrSvg`) plus a copyable link, and listens on a rendezvous relay + (`relay.nsec.app`); the signer app scans it and connects back, proving the + connection by echoing the client's random `secret`. The discovered signer + pubkey is then used for the durable connection. + + Requests are encrypted with **NIP-44** (with a NIP-04 decrypt fallback for + older signers); the vendored nostr-tools bundle has no nip46 module, so this + client is hand-rolled. The connection is **session-only** — nothing is + persisted, so a reload returns to anonymous. The chosen mode is remembered in `voxvera_identity_mode` (and the npub in `voxvera_identity_npub` for display). On reload: NIP-07 re-fetches the pubkey; @@ -278,10 +285,11 @@ de-duplicated per author+`d` identifier, keeping the latest. - **The board is gated behind a connected Nostr identity.** On load it shows a 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 + signer** (either paste a `bunker://` link or scan a `nostrconnect://` QR; 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 70978d5..2050dde 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -198,19 +198,22 @@ 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 — 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. 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). +- **NIP-46 remote signer (shipped — editor *and* board, both connect paths).** + Both the editor's publishing identity and the board's login gate can connect a + remote signer two ways: **(a) `bunker://`** — paste a link the signer app + generated; and **(b) `nostrconnect://` (QR)** — *we* generate the link, show it + as a QR (and a copyable link for one-device use), and the signer app scans it + and connects back (verified by echoing our secret). Either way the user's + secret stays in their signer app (nsec.app, Amber, …) and the browser holds + only 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; the QR flow uses `relay.nsec.app` as the rendezvous relay. 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** (nothing is written to disk, so a reload returns to anonymous + / read-only). Covered by the cross-engine e2e suite for both paths on both + pages (bunker and nostrconnect), 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 e64bef7..0aa6740 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -397,7 +397,7 @@ test.describe("VoxVera static client", () => { const link = page.locator('.preview-band > .board-link a[href="board.html"]'); await expect(link).toHaveText("Bulletin board"); // The safety link sits alongside it, below the preview. - await expect(page.locator('.preview-band > .board-link a[href="/safety"]')).toHaveText("Safety & privacy"); + await expect(page.locator('.preview-band > .board-link a[href="/safety.html"]')).toHaveText("Safety & privacy"); // It is app chrome below the preview, not part of the printable sheet. await expect(page.locator(".container .board-link")).toHaveCount(0); // Screen-only — it must not appear in print output. @@ -741,7 +741,93 @@ test.describe("VoxVera static client", () => { expect(errors, errors.join("\n")).toHaveLength(0); }); + // A fake relay that also plays a signer "scanning" the nostrconnect QR: when it + // sees the client's listener subscription it reads the on-screen nostrconnect + // URI, echoes the secret back (the connect proof), then answers requests. + function nostrConnectSignerInit() { + 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; + } + class FakeWS { + constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); } + send(data) { + let m; try { m = JSON.parse(data); } catch (_) { return; } + const NT = window.NostrTools; + if (m[0] === "REQ") { + this.sub = m[1]; + const f = m[2] || {}; + if (f["#p"] && f.kinds && f.kinds.indexOf(24133) !== -1 && window.__nip46SkHex) { + const clientPubkey = f["#p"][0]; + const uriEl = document.getElementById("nip46-uri") || document.getElementById("board-nip46-uri"); + const uri = uriEl ? uriEl.value : ""; + const mm = uri.match(/secret=([0-9a-f]+)/i); + if (mm) { + const sk = hexToBytes(window.__nip46SkHex); + const ck = NT.nip44.getConversationKey(sk, clientPubkey); + const content = NT.nip44.encrypt(JSON.stringify({ id: "echo", result: mm[1] }), ck); + const echo = NT.finalizeEvent({ kind: 24133, created_at: Math.floor(Date.now() / 1000), tags: [["p", clientPubkey]], content }, sk); + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", this.sub, echo]) }), 5); + } + } + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EOSE", this.sub]) }), 1); + return; + } + if (m[0] !== "EVENT") return; + const ev = m[1]; + if (ev.kind === 24133) { + const sk = hexToBytes(window.__nip46SkHex); + const ck = NT.nip44.getConversationKey(sk, ev.pubkey); + let req; try { req = JSON.parse(NT.nip44.decrypt(ev.content, ck)); } catch (_) { return; } + let result = ""; + 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]), sk)); + else if (req.method === "connect") result = "ack"; + 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 }, sk); + const sub = this.sub; + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["EVENT", sub, resp]) }), 5); + return; + } + window.__published = window.__published || []; + window.__published.push(ev); + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", ev.id, true, ""]) }), 1); + } + close() {} + } + window.WebSocket = FakeWS; + } + + async function mintSignerKey(page) { + return 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; + }); + } + + test("editor connects via the NIP-46 nostrconnect (QR) flow", async ({ page }) => { + const errors = trackErrors(page); + await page.addInitScript(nostrConnectSignerInit); + await page.goto("/#editor"); + const signerPubkey = await mintSignerKey(page); + const expectedNpub = await page.evaluate((pk) => window.NostrTools.nip19.npubEncode(pk), signerPubkey); + await page.locator("#use-nip46-toggle").click(); + await page.locator("#nip46-scan").click(); + // A nostrconnect:// link + QR are produced for the signer to scan. + await expect(page.locator("#nip46-uri")).toHaveValue(/^nostrconnect:\/\//); + await expect(page.locator("#nip46-qr svg")).toHaveCount(1); + // The fake signer "scans" it and connects back → identity adopts the signer. + await expect(page.locator("#signer-state")).toHaveText("Publishing via remote signer"); + await expect(page.locator("#author-npub-output")).toHaveText(expectedNpub); + expect(errors, errors.join("\n")).toHaveLength(0); + }); + test("exports the current flyer design as a portable, identity-free JSON file", async ({ page }) => { + await page.goto("/#editor"); + // Wait for the editor to finish populating defaults before overwriting them await page.goto("/#editor"); // Wait for the editor to finish populating defaults before overwriting them // (otherwise setDefaultText can race ahead of fill() on some engines). @@ -1256,6 +1342,22 @@ test.describe("VoxVera static client", () => { expect(reports[0].tags.some((tg) => tg[0] === "p" && tg[1] === AUTHOR)).toBe(true); }); + test("board connects via the NIP-46 nostrconnect (QR) flow", async ({ page }) => { + const errors = trackErrors(page); + await page.addInitScript(nostrConnectSignerInit); + await page.goto("/board.html"); + const signerPubkey = await mintSignerKey(page); + await page.locator("#board-connect-nip46-toggle").click(); + await page.locator("#board-nip46-scan").click(); + // A nostrconnect:// link + QR are produced for the signer to scan. + await expect(page.locator("#board-nip46-uri")).toHaveValue(/^nostrconnect:\/\//); + await expect(page.locator("#board-nip46-qr svg")).toHaveCount(1); + // The fake signer "scans" and connects back → the board reveals as that key. + await expect(page.locator("#board-content")).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(signerPubkey); + expect(errors, errors.join("\n")).toHaveLength(0); + }); + test("safety page renders all sections, localizes, and is linked from editor + board", async ({ page }) => { const errors = trackErrors(page); await page.goto("/safety.html"); @@ -1270,11 +1372,12 @@ test.describe("VoxVera static client", () => { await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); await expect(page.locator("#safety-title")).toHaveText("الأمان والخصوصية"); await expect(page.locator("#safety-exposes li")).toHaveCount(7); - // Reachable from both the editor and the board (served at /safety on Vercel). + // Reachable from the editor and the board via a footer link. await page.goto("/"); - await expect(page.locator('.board-link a[href="/safety"]')).toHaveCount(1); + await expect(page.locator('.board-link a[href="/safety.html"]')).toHaveCount(1); await page.goto("/board.html"); - await expect(page.locator("#board-safety")).toHaveAttribute("href", "/safety"); + await expect(page.locator("#board-safety")).toHaveAttribute("href", "/safety.html"); + await expect(page.locator(".board-link #board-safety")).toBeVisible(); expect(errors, errors.join("\n")).toHaveLength(0); }); diff --git a/site/board.html b/site/board.html index 764a9d8..0f928af 100644 --- a/site/board.html +++ b/site/board.html @@ -27,7 +27,6 @@
Back to VoxVera - Safety & privacy
@@ -104,6 +112,8 @@ + +