diff --git a/README.md b/README.md index c197ed7..82405c3 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 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. +- **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. - **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. - **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. diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 52c50c7..78a3f63 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -129,7 +129,7 @@ The client and schema helper must: ## Publishing identity -The editor signs flyer events under one of three identities, chosen in the +The editor signs flyer events under one of four identities, chosen in the identity controls (`activeIdentity` / `signActiveEvent` in `nostr-client.js`): - **Anonymous (default).** The per-device key in `localStorage` @@ -143,12 +143,23 @@ 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. 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. +a session-only nsec or a remote-signer session falls back to anonymous. +`naddr`/poster-URL/`author npub` outputs all reflect the active identity. A +deliberate identity choice is also mirrored to `voxvera_connected_pubkey` so the +bulletin board reflects the same 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 @@ -160,8 +171,9 @@ but longer PINs are allowed and encouraged. > 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 +> targeted attacker. The NIP-07 extension path (secret never in the page) and the +> NIP-46 remote signer (secret stays in the signer app; the browser holds only an +> ephemeral key — paste a `bunker://` link) are the stronger options. See > [`roadmap.md`](roadmap.md). ## Editing, re-publishing, and deleting flyers diff --git a/docs/roadmap.md b/docs/roadmap.md index a10389f..e0f093f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -190,19 +190,29 @@ board can show them flyers from people they trust. **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 → + 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 + (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). +- **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 brute-forceable offline if the encrypted blob leaks — it guards casual snooping, not a targeted attacker. A **wrong-PIN lockout** is now in place (3 bad PINs → - a 30s cooldown, persisted across reloads). Still open: NIP-46 remote signer, - 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. + a 30s cooldown, persisted across reloads). Optionally encrypting the anonymous + device key the same way is still open (today it is stored in plaintext, as + before). +- **Editor↔board identity sync (shipped).** A deliberate editor identity choice + (NIP-07 / imported / generated / unlocked nsec / remote signer) writes a shared + local pubkey the board reads, so one connected identity is reflected across both + pages. ## 3. Client distribution is centralized (a single chokepoint) — **Open** diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 36af522..f09063b 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -669,6 +669,77 @@ test.describe("VoxVera static client", () => { expect(await page.evaluate(() => localStorage.getItem("voxvera_connected_pubkey"))).toBe(PK); }); + test("editor connects a NIP-46 remote signer and publishes through it", async ({ page }) => { + const errors = trackErrors(page); + // A fake relay that also plays the remote signer: it decrypts NIP-46 + // requests with real NIP-44 crypto and replies with signed responses. + await page.addInitScript(() => { + 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; } + if (m[0] === "REQ") { + this.sub = m[1]; + setTimeout(() => 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; + } + // A published flyer event → acknowledge with OK. + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", ev.id, true, ""]) }), 1); + } + close() {} + } + window.WebSocket = FakeWS; + }); + await page.goto("/#editor"); + // Mint the signer keypair in-page (NostrTools is loaded there). + 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; + }); + const expectedNpub = await page.evaluate((pk) => window.NostrTools.nip19.npubEncode(pk), signerPubkey); + // Paste a bunker URI pointing at the fake signer and connect. + await page.locator("#use-nip46-toggle").click(); + await page.locator("#nip46-input").fill(`bunker://${signerPubkey}?relay=wss://relay.damus.io&secret=hunter2`); + await page.locator("#nip46-submit").click(); + // The connect + get_public_key handshake resolves the user's identity. + await expect(page.locator("#signer-state")).toHaveText("Publishing via remote signer"); + await expect(page.locator("#author-npub-output")).toHaveText(expectedNpub); + // Publishing routes signing through the remote signer (sign_event round-trip). + await page.locator("#field-title").fill("Remote Signed Flyer"); + await page.locator("#publish-event").click(); + await expect(page.locator("#event-id-output")).not.toHaveText(/not published/i); + expect(errors, errors.join("\n")).toHaveLength(0); + }); + test("editor can connect a NIP-07 identity and publish under it", async ({ page }) => { const PK = "a".repeat(64); await page.addInitScript((pk) => { diff --git a/site/index.html b/site/index.html index 0d6bb8a..e002b3f 100644 --- a/site/index.html +++ b/site/index.html @@ -51,6 +51,7 @@ + @@ -63,6 +64,15 @@ + +