diff --git a/README.md b/README.md index 7219ea1..50cc243 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.** The client generates an anonymous browser-side key to sign events. A NIP-07 browser extension is detected and used if present, but is never required. +- **Anonymous by default, your identity if you want it.** The editor signs flyers under one of three identities: an **anonymous** per-device key (default, no setup), a **NIP-07 browser extension** (your secret never touches the page), or an **imported `nsec`**. 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 path is 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). - **Open from a link or ID.** Load a flyer from an `naddr`, `nevent1`, `note1`, raw 64-char event ID, or `nostr:` URL. The lookup field tolerates copy/paste whitespace and newlines, so an event ID copied off a printed tear-off (where it wraps across two lines) still resolves. - **Short, scannable poster URLs.** Each poster URL is a standard NIP-19 `naddr` carrying a single relay hint, placed in the URL `#fragment` (no `?addr=` query). Shorter URLs make the tear-off QR code easier to scan. Older relay-list `naddr`s and `?addr=` URLs still resolve. @@ -19,7 +19,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **US Letter and A4.** Print size defaults to the right paper for the visitor's country (from the browser locale's region — `en-US` → Letter, `en-GB` → A4, no geolocation), with a manual override. Paper size is a local print preference and is never written into the event. See the [spec](docs/nostr-static-client-spec.md#print-paper-size). - **Fits-the-page editing.** Fields that would overflow the printable sheet are rejected as you type, with an inline error under the affected field. See the [field length cheat sheet](docs/flyer-field-limits.md) for how much text fits each field per language and paper size. - **QR codes.** The tear-off QR points at the poster URL (re-open and reprint); the main flyer QR points at the creator's content URL. -- **Bulletin board.** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, and date — sortable by any column. +- **Bulletin board (members-only, trust-filtered).** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, and date — sortable by any column. Viewing the board requires connecting a Nostr identity — via a NIP-07 browser extension, by pasting a private key (`nsec`, decoded in-page and never stored), or by creating a new key (revealed so you can save it). The board then shows only flyers from authors in your **web of trust** (your NIP-02 follow graph), with a "Show all" opt-out. A brand-new key with no follows is seeded from a curated trust anchor so the board is never an unmoderated firehose. Connecting is not required to author or print flyers, which stay fully anonymous. See the [roadmap](docs/roadmap.md) for the phased trust model. - **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`. ## What Is In `main` diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index bfddb6c..10f07b6 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -124,6 +124,43 @@ The client and schema helper must: - keep attachments unsupported in V1 - avoid remote frontend dependencies +## Publishing identity + +The editor signs flyer events under one of three identities, chosen in the +identity controls (`activeIdentity` / `signActiveEvent` in `nostr-client.js`): + +- **Anonymous (default).** The per-device key in `localStorage` + (`voxvera_nostr_anon_secret_hex`); signs locally with `finalizeEvent`. + Unchanged behavior — no connection required. "Generate anonymous npub" rolls a + fresh device key. +- **Browser extension (NIP-07).** Signs via `window.nostr.signEvent`; the secret + never enters the page. The pubkey is fetched with `getPublicKey()`. +- **Private key (nsec).** The user pastes an `nsec`; it is decoded to a secret + key used to sign with `finalizeEvent`. By default the secret lives **only in + 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. + +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; +a remembered nsec shows a **locked** state until the PIN is entered (`unlock`); +a session-only nsec falls back to anonymous. `naddr`/poster-URL/`author npub` +outputs all reflect the active identity. + +**PIN encryption details.** PBKDF2 (`SHA-256`, 600k iterations, random 16-byte +salt) derives an AES-GCM-256 key (random 12-byte IV); the blob stores +`{v, iters, salt, iv, ct}` base64. A minimum 4-digit numeric PIN is enforced, +but longer PINs are allowed and encouraged. + +> **Security caveat (documented deliberately).** A short numeric PIN has a tiny +> keyspace (a 4-digit PIN is ~10,000 combinations), so if the encrypted blob is +> exfiltrated (targeted XSS, or devtools access on a shared/stolen device) it is +> brute-forceable offline regardless of PBKDF2 cost. PIN-at-rest protects against +> casual snooping and untargeted localStorage scraping, **not** a determined or +> targeted attacker. The NIP-07 extension path (secret never in the page) is the +> stronger option; a NIP-46 remote signer is a future addition. See +> [`roadmap.md`](roadmap.md). + ## Print paper size The flyer supports two print sizes: **US Letter** (8.5×11in) and **A4** @@ -167,6 +204,34 @@ author+`d` identifier, keeping the latest. (`.board-link`, label `bulletin_board`), outside the printable sheet. It must 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` + (`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 + establishes the *viewer's* identity for the web-of-trust filter below. + Authoring and printing flyers stays fully anonymous and never requires + connecting. +- **Web-of-trust filter (NIP-02).** Once connected, the board fetches the + viewer's contact list (kind 3) and defaults to showing only flyers whose + author is in the viewer's follow graph (degree 1) plus the viewer themself. A + **"Show all"** checkbox opts out to the full relay firehose. A viewer with no + follow list of their own (a fresh key) is **seeded** from a pinned curator + account's follows (`FALLBACK_CURATOR_PUBKEY` in `board.js`), so a brand-new + viewer still gets a curated board. If no trust data can be fetched at all, the + board falls back to showing everything so it is never mysteriously empty. The + `#board-filter-note` explains which mode is active (following / seeded / all), + and re-localizes with the language selector. See [`roadmap.md`](roadmap.md) + for the phased plan (Phase 1 = the gate; Phase 2 = this filter; Phase 3 = + degree-2 trust, blocklist/report, NIP-05 badges). - `board.js` is independent of `nostr-client.js` (which exports nothing), so its UI strings live in `BOARD_UI` (all supported languages); it reuses the vendored nostr-tools and `locales.js` (for language names + RTL direction). @@ -180,8 +245,9 @@ author+`d` identifier, keeping the latest. - The npub column is forward-looking — anonymous keys today, but a stable identity per author maps cleanly onto this view later. -The board has no moderation and anonymous keys provide no authenticity yet; -these and other product/threat-model gaps are tracked in +The login gate (Phase 1) and the web-of-trust filter (Phase 2) are in place. +Remaining gaps — degree-2 trust, a blocklist/report flow, NIP-05 badges, and +making the bootstrap curator configurable — are tracked in [`roadmap.md`](roadmap.md). ## Localization diff --git a/docs/roadmap.md b/docs/roadmap.md index 94eac9e..f453dc8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,14 +7,17 @@ roughly in priority order: the trust model (#1–#2) is the highest-leverage are because almost every other risk flows from it being undefined. Status legend: **Open** (not started) · **Partial** (some mitigation exists) · -**Planned** (committed direction). +**Planned** (committed direction) · **In progress** (partially shipped). --- -## 1. Bulletin board has no moderation or abuse story — **Planned** +## 1. Bulletin board has no moderation or abuse story — **In progress** -> Direction agreed — see *Planned direction: identity + web-of-trust on the -> board* below (jointly addresses #1 and #2). +> Direction agreed and **Phases 1–2 shipped**: the board requires a connected +> Nostr identity to view, and it now filters to flyers from the viewer's NIP-02 +> web of trust (with a bootstrap seed for new keys). See *Planned direction: +> identity + web-of-trust on the board* below (jointly addresses #1 and #2). +> Remaining: degree-2 trust, blocklist/report, NIP-05 badges (Phase 3). **Problem.** `board.html` lists every Nostr event tagged `t=voxvera` / `t=flyer` @@ -44,7 +47,16 @@ the single biggest liability for promoting the board publicly. Depends on **#2** (without identity, allowlists/blocklists are weak). -## 2. Anonymous keys give no authenticity or accountability — **Planned** +## 2. Anonymous keys give no authenticity or accountability — **In progress** + +> Phases 1–2 shipped: the board viewer connects a real Nostr identity (NIP-07) +> and the board is filtered by that viewer's web of trust (with a curator seed +> for new keys), so spam from unknown authors is hidden by default. The **editor +> now lets authors publish under their own identity too** — NIP-07, an imported +> `nsec` (optionally PIN-encrypted at rest), or stay anonymous (default) — so a +> flyer can carry a stable, identifiable author when the creator wants one. +> Authenticity *display* on the board (NIP-05 badges, an identified/anonymous +> distinction) remains ahead. **Problem.** The client generates a fresh anonymous key per browser, so the board's "Posted by" npub is effectively random and there is no way to verify a @@ -94,23 +106,62 @@ board can show them flyers from people they trust. existing anonymous key to a kept identity. **Phased plan.** -- **Phase 1 — Identity.** A "Connect" flow (`window.nostr.getPublicKey()` with - an nsec-import fallback) establishes the viewer's npub; publish under that key - when connected; surface the connected npub. Anonymous create/print stays - intact. -- **Phase 2 — Web-of-trust filter.** Fetch the connected user's NIP-02 contact - list and default the board to flyers from authors in their follow graph, with - a "show all" opt-out. This is the spam gate. -- **Phase 3 — Optional.** Local blocklist / "hide this", report-to-list, NIP-05 - verified badges. +- **Phase 1 — Identity. ✅ Shipped.** The board (`board.html` / `board.js`) shows + a login gate instead of the table until the viewer connects. Three methods: a + NIP-07 extension (`window.nostr.getPublicKey()`), pasting an `nsec`, or creating + a new key (see decision 2 below). On success it reveals the table, surfaces the + connected npub with a Disconnect control, and remembers the pubkey in + `localStorage` (`voxvera_connected_pubkey`) so a return visit reconnects from + the pubkey alone — no prompt or re-entry, regardless of method. Anonymous + create/print is untouched — connecting is required only to *view the board*. +- **Phase 2 — Web-of-trust filter. ✅ Shipped.** On connect the board fetches + the viewer's NIP-02 contact list (kind 3) and defaults to flyers from authors + in their follow graph (degree 1) plus themselves, with a **"Show all"** + opt-out. **Bootstrap seed:** a viewer with no follow list of their own (a + fresh key) is seeded from a pinned curator account's follows + (`FALLBACK_CURATOR_PUBKEY` in `board.js` = + `npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx`), so a brand- + new viewer still gets a curated board instead of the unmoderated firehose. If + no trust data can be fetched at all, the board falls back to showing + everything (never mysteriously empty). Covered by the cross-engine e2e suite + (followed-only view + show-all opt-out; curator-seeded fallback). +- **Phase 3 — Optional / next.** Degree-2 trust (follows-of-follows), a local + blocklist / "hide this", report-to-list, and NIP-05 verified badges. Also: + the bootstrap curator is currently a single hardcoded pubkey — consider making + it configurable or a small curated set. -**Open decisions (need a call before building).** -1. **Board viewing:** login-required (Nostr-native, smaller insider audience) vs - open-with-WoT-applied-when-logged-in (broader physical-flyer reach; logged-out - sees a curated/limited set). Leaning open-with-WoT, but depends on whether the - board is a discovery surface or a members' space. -2. **Login methods at launch:** NIP-07 only, or NIP-07 + nsec import (so mobile - isn't excluded). +**Decisions made (2026-06-10).** +1. **Board viewing: login-required.** The board is blank (a Connect prompt) + until a Nostr identity is connected — framed as a members' space rather than a + public discovery surface. +2. **Login methods: NIP-07 + nsec import + key generation. ✅ Shipped.** The gate + offers three ways in: a NIP-07 browser extension, pasting a private key + (`nsec`), or creating a new key. The nsec is decoded **in-page** to derive the + pubkey and then discarded — only the pubkey is persisted, since the board only + reads (never signs). "Create a new key" reuses this device's existing + anonymous key if present (so it doesn't orphan an editor identity) or + generates one, and reveals the `nsec` with a "save this — we can't recover it" + warning before continuing. This **resolves the earlier mobile-lockout + trade-off**: a phone visitor with no extension can now generate or paste a key + to view the board. Covered by the cross-engine e2e suite (nsec import + + pubkey-only persistence + reload restore; invalid nsec; key generation + + reveal + connect). + +**Follow-ups this opens.** +- **In-page nsec handling.** Pasting a secret key into a web page is inherently + 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, but + a NIP-46 remote signer would be a safer mobile path to add later. +- **Editor identity (shipped).** The editor now signs under anon / NIP-07 / + imported nsec. A remembered nsec is PIN-encrypted at rest (PBKDF2 600k → + AES-GCM); plaintext is never stored. **Known weakness:** a short numeric PIN is + brute-forceable offline if the encrypted blob leaks — it guards casual snooping, + not a targeted attacker. Next: NIP-46 remote signer; an attempt-limit / lockout + on repeated wrong PINs; and optionally encrypting the anonymous device key the + same way (today it is stored in plaintext, as before). +- **Editor vs board identity are independent.** The editor's publishing identity + and the board's viewing identity are stored separately and don't yet sync; a + future pass could share one connected identity across both pages. ## 3. Client distribution is centralized (a single chokepoint) — **Open** diff --git a/e2e/README.md b/e2e/README.md index 8f3a429..84b594b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -32,6 +32,23 @@ engine-sensitive surfaces: - anonymous key generation works (the **WebCrypto** path) - the legacy `/nostr/` redirect preserves the query string and `#hash` - an event-lookup URL shows the localized loading state, never the default flyer +- the bulletin board is gated behind a Nostr connection: the gate shows when no + identity is connected, connecting (stubbed `window.nostr`) reveals the table + and the connected npub, disconnecting returns to the gate, and a remembered + connection auto-restores on revisit +- the board's alternative connect methods: pasting an `nsec` derives the pubkey, + reveals the board, leaves no secret in the DOM or storage, and restores on + reload from the pubkey alone; an invalid `nsec` errors without connecting; and + "Create a new key" reveals the npub/nsec, stores the device key, then connects +- the editor's publishing identity: defaults to anonymous; connecting NIP-07 + (stubbed) signs/publishes under the extension key; importing an `nsec` signs + for the session without persisting the secret; an invalid `nsec` errors; a + remembered `nsec` is PIN-encrypted at rest (the stored blob never contains the + plaintext secret), locks on reload, rejects a wrong PIN, and unlocks with the + right one; "Forget" clears the stored identity back to anonymous +- the board's web-of-trust filter (stubbed relays): a connected viewer sees only + flyers from authors in their NIP-02 follow graph, "Show all" opts out to the + full set, and a viewer with no follow list is seeded from the curator's follows ## Running diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 8cc2a07..6bf8036 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -428,6 +428,124 @@ test.describe("VoxVera static client", () => { await expect(modal).toBeHidden(); }); + test("editor defaults to anonymous signing", async ({ page }) => { + await page.goto("/#editor"); + await expect(page.locator("#signer-state")).toHaveText("Publishing anonymously"); + }); + + test("editor can connect a NIP-07 identity and publish under it", async ({ page }) => { + const PK = "a".repeat(64); + await page.addInitScript((pk) => { + window.nostr = { + getPublicKey: async () => pk, + // Return a plausibly-signed event so the publish path completes. + signEvent: async (e) => ({ ...e, pubkey: pk, id: "f".repeat(64), sig: "0".repeat(128) }) + }; + class FakeWS { + constructor() { this.readyState = 1; setTimeout(() => this.onopen && this.onopen(), 1); } + send(data) { + try { + const m = JSON.parse(data); + if (m[0] === "EVENT" && m[1] && m[1].id) { + const id = m[1].id; + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", id, true, ""]) }), 1); + } + } catch (_) {} + } + close() {} + } + window.WebSocket = FakeWS; + }, PK); + await page.goto("/#editor"); + const expectedNpub = await page.evaluate((pk) => window.NostrTools.nip19.npubEncode(pk), PK); + await page.locator("#connect-nip07").click(); + await expect(page.locator("#signer-state")).toHaveText("Publishing as your extension identity"); + await expect(page.locator("#author-npub-output")).toHaveText(expectedNpub); + // Publishing routes through window.nostr.signEvent and still shows the modal. + await page.locator("#publish-event").click(); + await expect(page.locator("#publish-modal")).toBeVisible(); + }); + + test("editor imports an nsec for the session without storing the secret", async ({ page }) => { + await page.goto("/#editor"); + const { nsec, npub } = await page.evaluate(() => { + const t = window.NostrTools; + const sk = t.generateSecretKey(); + return { nsec: t.nip19.nsecEncode(sk), npub: t.nip19.npubEncode(t.getPublicKey(sk)) }; + }); + await page.locator("#use-nsec-toggle").click(); + await page.locator("#nsec-input").fill(nsec); + await page.locator("#nsec-submit").click(); + await expect(page.locator("#signer-state")).toHaveText("Publishing as your imported key"); + await expect(page.locator("#author-npub-output")).toHaveText(npub); + // No "remember" → nothing encrypted is stored, and the mode falls back to + // anonymous on reload (so no dead lock). + expect(await page.evaluate(() => localStorage.getItem("voxvera_identity_nsec_enc"))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem("voxvera_identity_mode"))).toBe("anon"); + }); + + test("an invalid nsec in the editor shows an error", async ({ page }) => { + await page.goto("/#editor"); + await page.locator("#use-nsec-toggle").click(); + await page.locator("#nsec-input").fill("nsec1clearlynotvalid"); + await page.locator("#nsec-submit").click(); + await expect(page.locator("#identity-error")).toContainText(/valid nsec/i); + await expect(page.locator("#signer-state")).toHaveText("Publishing anonymously"); + }); + + test("a remembered nsec is PIN-encrypted at rest and unlocks on reload", async ({ page }) => { + await page.goto("/#editor"); + const { nsec, npub, hex } = await page.evaluate(() => { + const t = window.NostrTools; + const sk = t.generateSecretKey(); + const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); + return { nsec: t.nip19.nsecEncode(sk), npub: t.nip19.npubEncode(t.getPublicKey(sk)), hex: toHex(sk) }; + }); + await page.locator("#use-nsec-toggle").click(); + await page.locator("#nsec-input").fill(nsec); + await page.locator("#nsec-remember").check(); + await page.locator("#nsec-pin").fill("13579"); + await page.locator("#nsec-submit").click(); + await expect(page.locator("#signer-state")).toHaveText("Publishing as your imported key"); + // What's stored is an AES-GCM blob, never the plaintext secret. + const stored = await page.evaluate(() => localStorage.getItem("voxvera_identity_nsec_enc")); + expect(stored).toBeTruthy(); + expect(stored).not.toContain(hex); + expect(stored).not.toContain(nsec); + expect(JSON.parse(stored)).toHaveProperty("ct"); + + // Reload → the identity is locked and requires the PIN. (reload() forces a + // real document load; goto("/#editor") here would be a same-document hash + // change that keeps JS memory, so the in-memory key would never clear.) + await page.reload(); + await expect(page.locator("#identity-locked-row")).toBeVisible(); + await expect(page.locator("#identity-state")).toHaveText("Locked"); + // Wrong PIN is rejected. + await page.locator("#unlock-pin").fill("00000"); + await page.locator("#unlock-key").click(); + await expect(page.locator("#identity-error")).toContainText(/Wrong PIN/i); + // Correct PIN unlocks and restores the imported identity. + await page.locator("#unlock-pin").fill("13579"); + await page.locator("#unlock-key").click(); + await expect(page.locator("#signer-state")).toHaveText("Publishing as your imported key"); + await expect(page.locator("#author-npub-output")).toHaveText(npub); + }); + + test("forgetting a stored identity clears it and returns to anonymous", async ({ page }) => { + await page.goto("/#editor"); + const nsec = await page.evaluate(() => window.NostrTools.nip19.nsecEncode(window.NostrTools.generateSecretKey())); + await page.locator("#use-nsec-toggle").click(); + await page.locator("#nsec-input").fill(nsec); + await page.locator("#nsec-remember").check(); + await page.locator("#nsec-pin").fill("2468"); + await page.locator("#nsec-submit").click(); + await page.reload(); + await expect(page.locator("#identity-locked-row")).toBeVisible(); + await page.locator("#forget-key").click(); + await expect(page.locator("#signer-state")).toHaveText("Publishing anonymously"); + expect(await page.evaluate(() => localStorage.getItem("voxvera_identity_nsec_enc"))).toBeNull(); + }); + test("bulletin board language selector localizes the page", async ({ page }) => { await page.goto("/board.html"); await expect(page.locator("#board-title")).toHaveText("Bulletin Board"); diff --git a/site/index.html b/site/index.html index 970e367..23b2780 100644 --- a/site/index.html +++ b/site/index.html @@ -45,8 +45,31 @@
Checking identity -
+
+ + + +
+ + + + +