From 861407cbf6bfbb021632d69af024a7a532151204 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:16:53 -0400 Subject: [PATCH] Editor: publish under your own identity (NIP-07 / nsec) with PIN-encrypted nsec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let authors publish a flyer under a stable, identifiable key when they want one, while keeping anonymous-by-default. The editor now signs under one of three identities, routed through activeIdentity()/signActiveEvent(): - Anonymous (default, unchanged): the per-device key, signed locally. - NIP-07 extension: signs via window.nostr.signEvent; the secret never enters the page. - Imported nsec: decoded to a key used to sign. By default the secret lives only in memory for the session. "Remember on this device" stores it encrypted with a PIN (PBKDF2 SHA-256, 600k iterations, random salt -> AES-GCM-256); the plaintext secret is never written to storage. On reload a remembered key is locked until the PIN is entered; a session-only key falls back to anonymous. The signer pill, author npub, naddr, and poster URL all follow the active identity. New identity controls are localized across all 14 languages. Security note (also in docs): a short numeric PIN is brute-forceable offline if the encrypted blob leaks, so it guards casual snooping, not a targeted attacker — the NIP-07 path is stronger, and NIP-46 is a documented follow-up. The minimum PIN is 4 digits but longer is allowed and encouraged. Also updates README, the client spec (new "Publishing identity" section), the roadmap (#1/#2 marked in progress with phases shipped), and the e2e README. Covered by the cross-engine Playwright suite (default-anonymous, NIP-07 connect + publish, session-only nsec without persistence, invalid nsec, PIN encrypt -> lock on reload -> wrong/right PIN -> unlock, and forget). Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +- docs/nostr-static-client-spec.md | 70 +++- docs/roadmap.md | 93 +++-- e2e/README.md | 17 + e2e/flyer.spec.js | 118 +++++++ site/index.html | 25 +- site/nostr-client.css | 77 ++++ site/nostr-client.js | 581 ++++++++++++++++++++++++++++++- 8 files changed, 943 insertions(+), 42 deletions(-) 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 -
+
+ + + +
+ + + + +