Editor: publish under your own identity (NIP-07 / nsec) with PIN-encrypted nsec

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-10 10:16:53 -04:00
1 parent 1297a73332
commit 861407cbf6
8 files changed
+943 -42

No files matched your search

+2 -2
View File
@@ -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`
+68 -2
View File
@@ -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
+72 -21
View File
@@ -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**
+17
View File
@@ -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
+118
View File
@@ -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");
+24 -1
View File
@@ -45,8 +45,31 @@
</label>
<div class="identity-row">
<span id="identity-state" class="state-pill">Checking identity</span>
<button type="button" id="generate-anon-identity" class="secondary-button">Generate anonymous npub</button>
</div>
<div class="identity-controls">
<button type="button" id="generate-anon-identity" class="secondary-button">Generate anonymous npub</button>
<button type="button" id="connect-nip07" class="secondary-button">Connect extension</button>
<button type="button" id="use-nsec-toggle" class="secondary-button">Use a private key (nsec)</button>
</div>
<!-- Import a private key. The secret signs in-page; it is only written
to storage (encrypted with the PIN) when "Remember" is checked. -->
<div id="nsec-import" class="identity-import" hidden>
<input id="nsec-input" type="password" autocomplete="off" spellcheck="false" placeholder="nsec1…">
<label class="nsec-remember-row">
<input type="checkbox" id="nsec-remember">
<span data-i18n-key="remember_pin">Remember on this device (PIN)</span>
</label>
<input id="nsec-pin" type="password" inputmode="numeric" autocomplete="off" placeholder="PIN (4+ digits)" hidden>
<button type="button" id="nsec-submit" class="secondary-button">Use this key</button>
</div>
<!-- Shown on load when a PIN-encrypted identity is stored. -->
<div id="identity-locked-row" class="identity-import" hidden>
<span id="identity-locked-note" data-i18n-key="identity_locked">Saved identity is locked. Enter your PIN to unlock it.</span>
<input id="unlock-pin" type="password" inputmode="numeric" autocomplete="off" placeholder="PIN">
<button type="button" id="unlock-key" class="secondary-button">Unlock</button>
<button type="button" id="forget-key" class="secondary-button">Forget</button>
</div>
<p id="identity-error" class="field-error" role="alert" hidden></p>
<div class="field-grid">
<label>
+77
View File
@@ -190,6 +190,83 @@ main {
font-size: 12px;
}
/* Identity mode controls (anonymous / NIP-07 / imported nsec). */
.identity-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 10px;
}
.identity-controls .secondary-button {
min-height: 32px;
padding: 5px 10px;
font-size: 12px;
}
/* Vertical stack so the input, the "remember" checkbox, the PIN field, and the
action button each get their own line (a wrapping row collided the label and
button). */
.identity-import {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
margin: 0 0 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--soft);
}
.identity-import input[type="password"] {
width: 100%;
box-sizing: border-box;
font: inherit;
padding: 6px 8px;
border: 1px solid var(--line);
border-radius: 6px;
}
.identity-import .secondary-button {
align-self: flex-start;
min-height: 32px;
padding: 5px 12px;
font-size: 12px;
}
.identity-import .nsec-remember-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.identity-import .nsec-remember-row input {
width: auto;
flex: 0 0 auto;
margin: 0;
}
/* The locked row keeps the note on its own line, then PIN + buttons below. */
#identity-locked-row {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
#identity-locked-note {
font-size: 12px;
color: var(--muted);
flex: 1 1 100%;
}
#identity-locked-row input[type="password"] {
width: auto;
flex: 1 1 160px;
}
.editor-form label {
display: grid;
gap: 5px;
+565 -16
View File
@@ -7,6 +7,13 @@
"wss://relay.primal.net"
];
const ANON_SECRET_STORAGE_KEY = "voxvera_nostr_anon_secret_hex";
// Publishing identity ("anon" | "nip07" | "nsec") and, for an nsec the user
// chose to remember, its PIN-encrypted secret. The plaintext secret is never
// stored for nsec — only this AES-GCM blob, decrypted in memory on unlock.
const IDENTITY_MODE_STORAGE_KEY = "voxvera_identity_mode";
const IDENTITY_ENC_STORAGE_KEY = "voxvera_identity_nsec_enc";
const IDENTITY_NPUB_STORAGE_KEY = "voxvera_identity_npub";
const PBKDF2_ITERATIONS = 600000;
const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang";
const LOCALES = window.VoxVeraLocales || {};
const FALLBACK_LANG = "en";
@@ -104,6 +111,11 @@ Join us in a revolution that values truth and transparency. Together, we can bui
"viewer-editor": "editor",
"close-viewer-controls": "back",
"generate-anon-identity": "generate_npub",
"connect-nip07": "connect_extension",
"use-nsec-toggle": "use_nsec",
"nsec-submit": "use_key",
"unlock-key": "unlock",
"forget-key": "forget_key",
"viewer-fetch-render": "fetch_render",
"editor-tab": "editor",
"viewer-tab": "viewer"
@@ -188,6 +200,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "فحص الهوية",
anon_signing_nip07: "التوقيع المجهول هو الافتراضي؛ تم اكتشاف NIP-07",
anon_signing_ready: "التوقيع المجهول متاح؛ لا يلزم تسجيل دخول NIP-07",
connect_extension: "ربط الإضافة",
use_nsec: "استخدام مفتاح خاص (nsec)",
use_key: "استخدام هذا المفتاح",
remember_pin: "تذكُّر على هذا الجهاز (رمز PIN)",
pin_prompt: "رمز PIN (4 أرقام أو أكثر)",
unlock: "فتح القفل",
forget_key: "نسيان",
invalid_nsec: "لا يبدو هذا مفتاح nsec صالحًا.",
pin_short: "يجب أن يتكون رمز PIN من 4 أرقام على الأقل.",
pin_wrong: "رمز PIN غير صحيح. حاول مرة أخرى.",
nip07_missing: "لم يتم العثور على إضافة NIP-07.",
identity_locked: "الهوية المحفوظة مقفلة. أدخل رمز PIN لفتحها.",
locked: "مقفل",
signer_anon: "النشر بشكل مجهول",
signer_nip07: "النشر بهوية الإضافة الخاصة بك",
signer_nsec: "النشر بمفتاحك المستورد",
no_anon_npub: "لا يوجد npub مجهول بعد",
anon_npub_ready: "npub مجهول جاهز",
npub_generated: "تم إنشاء npub مجهول:",
@@ -249,6 +277,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Identität prüfen",
anon_signing_nip07: "Anonymes Signieren ist Standard; NIP-07 erkannt",
anon_signing_ready: "Anonymes Signieren verfügbar; kein NIP-07-Login nötig",
connect_extension: "Erweiterung verbinden",
use_nsec: "Privaten Schlüssel (nsec) verwenden",
use_key: "Diesen Schlüssel verwenden",
remember_pin: "Auf diesem Gerät merken (PIN)",
pin_prompt: "PIN (mind. 4 Ziffern)",
unlock: "Entsperren",
forget_key: "Verwerfen",
invalid_nsec: "Das sieht nicht nach einem gültigen nsec-Schlüssel aus.",
pin_short: "Die PIN muss mindestens 4 Ziffern haben.",
pin_wrong: "Falsche PIN. Bitte erneut versuchen.",
nip07_missing: "Keine NIP-07-Erweiterung gefunden.",
identity_locked: "Gespeicherte Identität ist gesperrt. Gib deine PIN ein, um sie zu entsperren.",
locked: "Gesperrt",
signer_anon: "Anonym veröffentlichen",
signer_nip07: "Veröffentlichung mit deiner Erweiterungs-Identität",
signer_nsec: "Veröffentlichung mit deinem importierten Schlüssel",
no_anon_npub: "Noch kein anonymes npub",
anon_npub_ready: "Anonymes npub bereit",
npub_generated: "Anonymes npub erzeugt:",
@@ -310,6 +354,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Checking identity",
anon_signing_nip07: "Anonymous signing is default; NIP-07 detected",
anon_signing_ready: "Anonymous signing available; no NIP-07 login required",
connect_extension: "Connect extension",
use_nsec: "Use a private key (nsec)",
use_key: "Use this key",
remember_pin: "Remember on this device (PIN)",
pin_prompt: "PIN (4+ digits)",
unlock: "Unlock",
forget_key: "Forget",
invalid_nsec: "That doesn't look like a valid nsec key.",
pin_short: "PIN must be at least 4 digits.",
pin_wrong: "Wrong PIN. Try again.",
nip07_missing: "No NIP-07 extension found.",
identity_locked: "Saved identity is locked. Enter your PIN to unlock it.",
locked: "Locked",
signer_anon: "Publishing anonymously",
signer_nip07: "Publishing as your extension identity",
signer_nsec: "Publishing as your imported key",
no_anon_npub: "No anonymous npub yet",
anon_npub_ready: "Anonymous npub ready",
npub_generated: "Anonymous npub generated:",
@@ -372,6 +432,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Comprobando identidad",
anon_signing_nip07: "La firma anónima es predeterminada; NIP-07 detectado",
anon_signing_ready: "Firma anónima disponible; no se requiere inicio de sesión NIP-07",
connect_extension: "Conectar extensión",
use_nsec: "Usar una clave privada (nsec)",
use_key: "Usar esta clave",
remember_pin: "Recordar en este dispositivo (PIN)",
pin_prompt: "PIN (4 o más dígitos)",
unlock: "Desbloquear",
forget_key: "Olvidar",
invalid_nsec: "Eso no parece una clave nsec válida.",
pin_short: "El PIN debe tener al menos 4 dígitos.",
pin_wrong: "PIN incorrecto. Inténtalo de nuevo.",
nip07_missing: "No se encontró ninguna extensión NIP-07.",
identity_locked: "La identidad guardada está bloqueada. Introduce tu PIN para desbloquearla.",
locked: "Bloqueada",
signer_anon: "Publicando de forma anónima",
signer_nip07: "Publicando con la identidad de tu extensión",
signer_nsec: "Publicando con tu clave importada",
no_anon_npub: "Aún no hay npub anónimo",
anon_npub_ready: "npub anónimo listo",
npub_generated: "npub anónimo generado:",
@@ -433,6 +509,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "بررسی هویت",
anon_signing_nip07: "امضای ناشناس پیش‌فرض است؛ NIP-07 شناسایی شد",
anon_signing_ready: "امضای ناشناس آماده است؛ ورود NIP-07 لازم نیست",
connect_extension: "اتصال افزونه",
use_nsec: "استفاده از کلید خصوصی (nsec)",
use_key: "استفاده از این کلید",
remember_pin: "روی این دستگاه به خاطر بسپار (PIN)",
pin_prompt: "PIN (۴ رقم یا بیشتر)",
unlock: "باز کردن قفل",
forget_key: "فراموش کردن",
invalid_nsec: "این یک کلید nsec معتبر به نظر نمی‌رسد.",
pin_short: "PIN باید حداقل ۴ رقم باشد.",
pin_wrong: "PIN نادرست است. دوباره تلاش کنید.",
nip07_missing: "هیچ افزونه NIP-07 یافت نشد.",
identity_locked: "هویت ذخیره‌شده قفل است. برای باز کردن آن PIN خود را وارد کنید.",
locked: "قفل‌شده",
signer_anon: "انتشار به‌صورت ناشناس",
signer_nip07: "انتشار با هویت افزونه شما",
signer_nsec: "انتشار با کلید واردشده شما",
no_anon_npub: "هنوز npub ناشناس وجود ندارد",
anon_npub_ready: "npub ناشناس آماده است",
npub_generated: "npub ناشناس ساخته شد:",
@@ -494,6 +586,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Verification de l'identite",
anon_signing_nip07: "La signature anonyme est par defaut; NIP-07 detecte",
anon_signing_ready: "Signature anonyme disponible; aucune connexion NIP-07 requise",
connect_extension: "Connecter l'extension",
use_nsec: "Utiliser une clé privée (nsec)",
use_key: "Utiliser cette clé",
remember_pin: "Se souvenir sur cet appareil (PIN)",
pin_prompt: "PIN (4 chiffres ou plus)",
unlock: "Déverrouiller",
forget_key: "Oublier",
invalid_nsec: "Cela ne ressemble pas à une clé nsec valide.",
pin_short: "Le PIN doit comporter au moins 4 chiffres.",
pin_wrong: "PIN incorrect. Veuillez réessayer.",
nip07_missing: "Aucune extension NIP-07 trouvée.",
identity_locked: "L'identité enregistrée est verrouillée. Saisissez votre PIN pour la déverrouiller.",
locked: "Verrouillée",
signer_anon: "Publication anonyme",
signer_nip07: "Publication avec l'identité de votre extension",
signer_nsec: "Publication avec votre clé importée",
no_anon_npub: "Aucun npub anonyme pour le moment",
anon_npub_ready: "npub anonyme pret",
npub_generated: "npub anonyme genere :",
@@ -555,6 +663,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "בודק זהות",
anon_signing_nip07: "חתימה אנונימית היא ברירת המחדל; NIP-07 זוהה",
anon_signing_ready: "חתימה אנונימית זמינה; אין צורך בהתחברות NIP-07",
connect_extension: "חבר תוסף",
use_nsec: "השתמש במפתח פרטי (nsec)",
use_key: "השתמש במפתח זה",
remember_pin: "זכור במכשיר זה (PIN)",
pin_prompt: "PIN (4 ספרות או יותר)",
unlock: "בטל נעילה",
forget_key: "שכח",
invalid_nsec: "זה לא נראה כמו מפתח nsec תקין.",
pin_short: "ה-PIN חייב להכיל לפחות 4 ספרות.",
pin_wrong: "PIN שגוי. נסה שוב.",
nip07_missing: "לא נמצא תוסף NIP-07.",
identity_locked: "הזהות השמורה נעולה. הזן את ה-PIN שלך כדי לבטל את הנעילה.",
locked: "נעול",
signer_anon: "מפרסם באופן אנונימי",
signer_nip07: "מפרסם עם זהות התוסף שלך",
signer_nsec: "מפרסם עם המפתח המיובא שלך",
no_anon_npub: "עדיין אין npub אנונימי",
anon_npub_ready: "npub אנונימי מוכן",
npub_generated: "npub אנונימי נוצר:",
@@ -616,6 +740,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "पहचान जांच रहे हैं",
anon_signing_nip07: "अनाम हस्ताक्षर डिफ़ॉल्ट है; NIP-07 मिला",
anon_signing_ready: "अनाम हस्ताक्षर उपलब्ध; NIP-07 लॉगिन आवश्यक नहीं",
connect_extension: "एक्सटेंशन कनेक्ट करें",
use_nsec: "निजी कुंजी (nsec) का उपयोग करें",
use_key: "इस कुंजी का उपयोग करें",
remember_pin: "इस डिवाइस पर याद रखें (PIN)",
pin_prompt: "PIN (4+ अंक)",
unlock: "अनलॉक करें",
forget_key: "भूल जाएँ",
invalid_nsec: "यह एक मान्य nsec कुंजी नहीं लगती।",
pin_short: "PIN कम से कम 4 अंकों का होना चाहिए।",
pin_wrong: "गलत PIN। पुनः प्रयास करें।",
nip07_missing: "कोई NIP-07 एक्सटेंशन नहीं मिला।",
identity_locked: "सहेजी गई पहचान लॉक है। इसे अनलॉक करने के लिए अपना PIN दर्ज करें।",
locked: "लॉक",
signer_anon: "अनाम रूप से प्रकाशित किया जा रहा है",
signer_nip07: "आपकी एक्सटेंशन पहचान से प्रकाशित किया जा रहा है",
signer_nsec: "आपकी आयातित कुंजी से प्रकाशित किया जा रहा है",
no_anon_npub: "अभी कोई अनाम npub नहीं",
anon_npub_ready: "अनाम npub तैयार",
npub_generated: "अनाम npub बनाया गया:",
@@ -677,6 +817,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "IDを確認中",
anon_signing_nip07: "匿名署名が標準です; NIP-07を検出しました",
anon_signing_ready: "匿名署名を利用できます; NIP-07ログインは不要です",
connect_extension: "拡張機能を接続",
use_nsec: "秘密鍵 (nsec) を使う",
use_key: "この鍵を使う",
remember_pin: "このデバイスで記憶する (PIN)",
pin_prompt: "PIN(4桁以上)",
unlock: "ロック解除",
forget_key: "破棄",
invalid_nsec: "有効な nsec 鍵ではないようです。",
pin_short: "PIN は4桁以上にしてください。",
pin_wrong: "PIN が違います。もう一度お試しください。",
nip07_missing: "NIP-07 拡張機能が見つかりません。",
identity_locked: "保存された ID はロックされています。PIN を入力して解除してください。",
locked: "ロック中",
signer_anon: "匿名で公開します",
signer_nip07: "拡張機能の ID で公開します",
signer_nsec: "インポートした鍵で公開します",
no_anon_npub: "匿名npubはまだありません",
anon_npub_ready: "匿名npub準備完了",
npub_generated: "匿名npubを生成しました:",
@@ -738,6 +894,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Verificando identidade",
anon_signing_nip07: "Assinatura anonima e o padrao; NIP-07 detectado",
anon_signing_ready: "Assinatura anonima disponivel; login NIP-07 nao necessario",
connect_extension: "Conectar extensão",
use_nsec: "Usar uma chave privada (nsec)",
use_key: "Usar esta chave",
remember_pin: "Lembrar neste dispositivo (PIN)",
pin_prompt: "PIN (4 ou mais dígitos)",
unlock: "Desbloquear",
forget_key: "Esquecer",
invalid_nsec: "Isso não parece uma chave nsec válida.",
pin_short: "O PIN deve ter pelo menos 4 dígitos.",
pin_wrong: "PIN incorreto. Tente novamente.",
nip07_missing: "Nenhuma extensão NIP-07 encontrada.",
identity_locked: "A identidade salva está bloqueada. Digite seu PIN para desbloqueá-la.",
locked: "Bloqueada",
signer_anon: "Publicando anonimamente",
signer_nip07: "Publicando com a identidade da sua extensão",
signer_nsec: "Publicando com sua chave importada",
no_anon_npub: "Ainda nao ha npub anonimo",
anon_npub_ready: "npub anonimo pronto",
npub_generated: "npub anonimo gerado:",
@@ -799,6 +971,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Проверка личности",
anon_signing_nip07: "Анонимная подпись используется по умолчанию; обнаружен NIP-07",
anon_signing_ready: "Анонимная подпись доступна; вход NIP-07 не требуется",
connect_extension: "Подключить расширение",
use_nsec: "Использовать приватный ключ (nsec)",
use_key: "Использовать этот ключ",
remember_pin: "Запомнить на этом устройстве (PIN)",
pin_prompt: "PIN (не менее 4 цифр)",
unlock: "Разблокировать",
forget_key: "Забыть",
invalid_nsec: "Это не похоже на действительный ключ nsec.",
pin_short: "PIN должен содержать не менее 4 цифр.",
pin_wrong: "Неверный PIN. Попробуйте снова.",
nip07_missing: "Расширение NIP-07 не найдено.",
identity_locked: "Сохранённая личность заблокирована. Введите PIN, чтобы разблокировать её.",
locked: "Заблокировано",
signer_anon: "Публикация анонимно",
signer_nip07: "Публикация под личностью вашего расширения",
signer_nsec: "Публикация вашим импортированным ключом",
no_anon_npub: "Анонимного npub пока нет",
anon_npub_ready: "Анонимный npub готов",
npub_generated: "Анонимный npub создан:",
@@ -860,6 +1048,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Inakagua utambulisho",
anon_signing_nip07: "Utiaji saini usiojulikana ndio chaguo msingi; NIP-07 imegunduliwa",
anon_signing_ready: "Utiaji saini usiojulikana unapatikana; hakuna kuingia kwa NIP-07 kunakohitajika",
connect_extension: "Unganisha kiendelezi",
use_nsec: "Tumia ufunguo wa faragha (nsec)",
use_key: "Tumia ufunguo huu",
remember_pin: "Kumbuka kwenye kifaa hiki (PIN)",
pin_prompt: "PIN (tarakimu 4 au zaidi)",
unlock: "Fungua",
forget_key: "Sahau",
invalid_nsec: "Huu hauonekani kama ufunguo halali wa nsec.",
pin_short: "PIN lazima iwe na tarakimu nne au zaidi.",
pin_wrong: "PIN si sahihi. Jaribu tena.",
nip07_missing: "Hakuna kiendelezi cha NIP-07 kilichopatikana.",
identity_locked: "Kitambulisho kilichohifadhiwa kimefungwa. Weka PIN yako kukifungua.",
locked: "Imefungwa",
signer_anon: "Inachapisha bila kujulikana",
signer_nip07: "Inachapisha kwa kitambulisho cha kiendelezi chako",
signer_nsec: "Inachapisha kwa ufunguo wako ulioingizwa",
no_anon_npub: "Hakuna npub isiyojulikana bado",
anon_npub_ready: "npub isiyojulikana iko tayari",
npub_generated: "npub isiyojulikana imetengenezwa:",
@@ -921,6 +1125,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "Kimlik kontrol ediliyor",
anon_signing_nip07: "Anonim imzalama varsayılandır; NIP-07 algılandı",
anon_signing_ready: "Anonim imzalama hazır; NIP-07 girişi gerekmez",
connect_extension: "Uzantıyı bağla",
use_nsec: "Özel anahtar (nsec) kullan",
use_key: "Bu anahtarı kullan",
remember_pin: "Bu cihazda hatırla (PIN)",
pin_prompt: "PIN (4+ rakam)",
unlock: "Kilidi aç",
forget_key: "Unut",
invalid_nsec: "Bu geçerli bir nsec anahtarı gibi görünmüyor.",
pin_short: "PIN en az 4 rakam olmalıdır.",
pin_wrong: "Yanlış PIN. Tekrar deneyin.",
nip07_missing: "NIP-07 uzantısı bulunamadı.",
identity_locked: "Kayıtlı kimlik kilitli. Kilidini açmak için PIN'inizi girin.",
locked: "Kilitli",
signer_anon: "Anonim olarak yayımlanıyor",
signer_nip07: "Uzantı kimliğinizle yayımlanıyor",
signer_nsec: "İçe aktardığınız anahtarla yayımlanıyor",
no_anon_npub: "Henüz anonim npub yok",
anon_npub_ready: "Anonim npub hazır",
npub_generated: "Anonim npub oluşturuldu:",
@@ -982,6 +1202,22 @@ Join us in a revolution that values truth and transparency. Together, we can bui
checking_identity: "正在检查身份",
anon_signing_nip07: "默认使用匿名签名;检测到 NIP-07",
anon_signing_ready: "匿名签名可用;无需 NIP-07 登录",
connect_extension: "连接扩展",
use_nsec: "使用私钥 (nsec)",
use_key: "使用此密钥",
remember_pin: "在此设备上记住 (PIN)",
pin_prompt: "PIN(4位以上)",
unlock: "解锁",
forget_key: "忘记",
invalid_nsec: "这看起来不是有效的 nsec 密钥。",
pin_short: "PIN 至少需要 4 位数字。",
pin_wrong: "PIN 错误。请重试。",
nip07_missing: "未找到 NIP-07 扩展。",
identity_locked: "已保存的身份已锁定。请输入 PIN 解锁。",
locked: "已锁定",
signer_anon: "正在匿名发布",
signer_nip07: "正在以你的扩展身份发布",
signer_nsec: "正在使用你导入的密钥发布",
no_anon_npub: "还没有匿名 npub",
anon_npub_ready: "匿名 npub 已就绪",
npub_generated: "已生成匿名 npub:",
@@ -1249,6 +1485,10 @@ Join us in a revolution that values truth and transparency. Together, we can bui
});
const viewerEventInput = el("viewer-event-id");
if (viewerEventInput) viewerEventInput.placeholder = nostrLabel("lookup_placeholder", selected);
["nsec-pin", "unlock-pin"].forEach((id) => {
const node = el(id);
if (node) node.placeholder = nostrLabel("pin_prompt", selected);
});
LANGUAGE_SELECT_IDS.forEach((selectId) => {
const select = el(selectId);
@@ -1416,12 +1656,11 @@ Join us in a revolution that values truth and transparency. Together, we can bui
function refreshSignerState() {
const state = el("signer-state");
if (window.nostr && typeof window.nostr.signEvent === "function") {
setLocalizedText("signer-state", "anon_signing_nip07");
state.style.color = "#1d6b3a";
return;
}
setLocalizedText("signer-state", "anon_signing_ready");
if (!state) return;
const key = identityMode === "nip07" ? "signer_nip07"
: identityMode === "nsec" ? "signer_nsec"
: "signer_anon";
setLocalizedText("signer-state", key);
state.style.color = "#1d6b3a";
}
@@ -1474,22 +1713,303 @@ Join us in a revolution that values truth and transparency. Together, we can bui
return { secretKey, secretHex, pubkey, npub, nsec };
}
// ---- Publishing identity (anon / NIP-07 / imported nsec) ----------------
// Flyers are signed under one of three identities. "anon" is the per-device
// key (default, unchanged). "nip07" signs via the browser extension. "nsec"
// signs with a key the user imported; its secret lives only in memory unless
// they choose to remember it, in which case it is PIN-encrypted at rest.
let identityMode = "anon"; // "anon" | "nip07" | "nsec"
let nip07Pubkey = null; // hex, when mode === "nip07"
let importedSecretHex = null; // hex, in-memory only, when mode === "nsec"
let importedPubkey = null; // hex, when mode === "nsec"
let lockedNpub = null; // npub of a stored-but-locked nsec identity
function npubFromHex(pubkeyHex) {
try { return nostrTools().nip19.npubEncode(pubkeyHex); } catch (_) { return pubkeyHex; }
}
// The identity flyers are currently published under: { mode, pubkey, npub }
// (never the secret). Falls back to the device anon key.
function activeIdentity() {
if (identityMode === "nip07" && nip07Pubkey) {
return { mode: "nip07", pubkey: nip07Pubkey, npub: npubFromHex(nip07Pubkey) };
}
if (identityMode === "nsec" && importedPubkey) {
return { mode: "nsec", pubkey: importedPubkey, npub: npubFromHex(importedPubkey) };
}
const anon = getOrCreateAnonIdentity(false);
return { mode: "anon", pubkey: anon.pubkey, npub: anon.npub };
}
// Sign an unsigned event with the active identity (extension or local key).
async function signActiveEvent(unsigned) {
const tools = nostrTools();
if (identityMode === "nip07") {
if (!(window.nostr && typeof window.nostr.signEvent === "function")) {
throw new Error(nostrLabel("nip07_missing", currentUiLang()));
}
return await window.nostr.signEvent({ ...unsigned, pubkey: nip07Pubkey });
}
if (identityMode === "nsec") {
if (!importedSecretHex) throw new Error(nostrLabel("identity_locked", currentUiLang()));
return tools.finalizeEvent(unsigned, hexToBytes(importedSecretHex));
}
return tools.finalizeEvent(unsigned, getOrCreateAnonIdentity(false).secretKey);
}
function setIdentityError(messageKey) {
const node = el("identity-error");
if (!node) return;
if (messageKey) {
node.hidden = false;
node.textContent = nostrLabel(messageKey, currentUiLang());
} else {
node.hidden = true;
node.textContent = "";
}
}
function persistMode(mode) {
try {
window.localStorage.setItem(IDENTITY_MODE_STORAGE_KEY, mode);
if (mode === "anon") {
window.localStorage.removeItem(IDENTITY_NPUB_STORAGE_KEY);
} else {
window.localStorage.setItem(IDENTITY_NPUB_STORAGE_KEY, activeIdentity().npub);
}
} catch (_) {}
}
// --- WebCrypto PIN encryption for a remembered nsec (PBKDF2 -> AES-GCM) ---
function b64encode(bytes) {
let bin = "";
bytes.forEach((b) => { bin += String.fromCharCode(b); });
return window.btoa(bin);
}
function b64decode(text) {
const bin = window.atob(text);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
return out;
}
async function deriveAesKey(pin, salt, usage) {
const subtle = window.crypto.subtle;
const material = await subtle.importKey(
"raw", new TextEncoder().encode(pin), "PBKDF2", false, ["deriveKey"]
);
return subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
material, { name: "AES-GCM", length: 256 }, false, usage
);
}
async function encryptSecretHex(secretHex, pin) {
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await deriveAesKey(pin, salt, ["encrypt"]);
const ct = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, key, new TextEncoder().encode(secretHex)
);
return {
v: 1, iters: PBKDF2_ITERATIONS,
salt: b64encode(salt), iv: b64encode(iv), ct: b64encode(new Uint8Array(ct))
};
}
async function decryptSecretHex(blob, pin) {
const salt = b64decode(blob.salt);
const iv = b64decode(blob.iv);
const key = await deriveAesKey(pin, salt, ["decrypt"]);
const pt = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64decode(blob.ct));
const hex = new TextDecoder().decode(pt).trim();
if (!/^[0-9a-f]{64}$/i.test(hex)) throw new Error("bad plaintext");
return hex.toLowerCase();
}
function getStoredEnc() {
try {
const raw = window.localStorage.getItem(IDENTITY_ENC_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (_) {
return null;
}
}
function showLockedRow(show) {
const row = el("identity-locked-row");
if (row) row.hidden = !show;
}
// --- Identity actions wired to the controls ---
function chooseAnonIdentity() {
identityMode = "anon";
nip07Pubkey = null;
setIdentityError(null);
const imp = el("nsec-import");
if (imp) imp.hidden = true;
persistMode("anon");
refreshSignerState();
refreshIdentityState();
}
async function connectNip07Identity() {
setIdentityError(null);
if (!(window.nostr && typeof window.nostr.getPublicKey === "function")) {
setIdentityError("nip07_missing");
return;
}
try {
const pk = await window.nostr.getPublicKey();
if (!/^[0-9a-f]{64}$/i.test(String(pk || ""))) throw new Error("bad pubkey");
nip07Pubkey = String(pk).toLowerCase();
identityMode = "nip07";
const imp = el("nsec-import");
if (imp) imp.hidden = true;
persistMode("nip07");
refreshSignerState();
refreshIdentityState();
} catch (_) {
setIdentityError("nip07_missing");
}
}
function toggleNsecImport() {
setIdentityError(null);
showLockedRow(false);
const row = el("nsec-import");
if (!row) return;
row.hidden = !row.hidden;
if (!row.hidden) { const i = el("nsec-input"); if (i) i.focus(); }
}
async function submitNsecImport() {
setIdentityError(null);
const tools = nostrTools();
const input = el("nsec-input");
const raw = String((input && input.value) || "").trim();
let secretBytes;
let pubkey;
try {
const decoded = tools.nip19.decode(raw);
if (decoded.type !== "nsec") throw new Error("not an nsec");
secretBytes = decoded.data;
pubkey = tools.getPublicKey(secretBytes);
} catch (_) {
setIdentityError("invalid_nsec");
return;
}
const remember = Boolean(el("nsec-remember") && el("nsec-remember").checked);
if (remember) {
const pin = String((el("nsec-pin") && el("nsec-pin").value) || "");
if (!/^[0-9]{4,}$/.test(pin)) { setIdentityError("pin_short"); return; }
try {
const blob = await encryptSecretHex(bytesToHex(secretBytes), pin);
window.localStorage.setItem(IDENTITY_ENC_STORAGE_KEY, JSON.stringify(blob));
} catch (_) {
setIdentityError("invalid_nsec");
return;
}
} else {
try { window.localStorage.removeItem(IDENTITY_ENC_STORAGE_KEY); } catch (_) {}
}
importedSecretHex = bytesToHex(secretBytes);
importedPubkey = String(pubkey).toLowerCase();
lockedNpub = npubFromHex(importedPubkey);
identityMode = "nsec";
if (input) input.value = "";
if (el("nsec-pin")) el("nsec-pin").value = "";
if (el("nsec-import")) el("nsec-import").hidden = true;
// Remembered keys persist mode "nsec" (locked on reload); session-only keys
// persist "anon" so a reload returns cleanly to anonymous, not a dead lock.
persistMode(remember ? "nsec" : "anon");
refreshSignerState();
refreshIdentityState();
}
async function unlockIdentity() {
setIdentityError(null);
const blob = getStoredEnc();
if (!blob) { showLockedRow(false); chooseAnonIdentity(); return; }
const pin = String((el("unlock-pin") && el("unlock-pin").value) || "");
let hex;
try {
hex = await decryptSecretHex(blob, pin);
} catch (_) {
setIdentityError("pin_wrong");
return;
}
importedSecretHex = hex;
importedPubkey = nostrTools().getPublicKey(hexToBytes(hex));
lockedNpub = npubFromHex(importedPubkey);
identityMode = "nsec";
if (el("unlock-pin")) el("unlock-pin").value = "";
showLockedRow(false);
persistMode("nsec");
refreshSignerState();
refreshIdentityState();
}
function forgetStoredIdentity() {
try {
window.localStorage.removeItem(IDENTITY_ENC_STORAGE_KEY);
window.localStorage.removeItem(IDENTITY_NPUB_STORAGE_KEY);
} catch (_) {}
importedSecretHex = null;
importedPubkey = null;
lockedNpub = null;
showLockedRow(false);
chooseAnonIdentity();
}
// Restore the chosen identity on load (called from the DOMContentLoaded init).
function restoreIdentity() {
let mode = "anon";
try { mode = window.localStorage.getItem(IDENTITY_MODE_STORAGE_KEY) || "anon"; } catch (_) {}
try { lockedNpub = window.localStorage.getItem(IDENTITY_NPUB_STORAGE_KEY) || null; } catch (_) {}
if (mode === "nip07") {
identityMode = "nip07";
if (window.nostr && typeof window.nostr.getPublicKey === "function") {
window.nostr.getPublicKey().then((pk) => {
if (/^[0-9a-f]{64}$/i.test(String(pk || ""))) {
nip07Pubkey = String(pk).toLowerCase();
refreshSignerState();
refreshIdentityState();
}
}).catch(() => {});
}
} else if (mode === "nsec" && getStoredEnc()) {
identityMode = "nsec"; // locked until the PIN is entered
showLockedRow(true);
} else {
identityMode = "anon";
}
refreshSignerState();
refreshIdentityState();
}
function refreshIdentityState() {
const state = el("identity-state");
const output = el("author-npub-output");
if (!state || !output) return;
try {
const secretHex = getStoredAnonSecret();
if (!secretHex) {
// nsec mode but no secret in memory yet → it is stored and locked.
if (identityMode === "nsec" && !importedPubkey) {
setLocalizedText("identity-state", "locked");
state.style.color = "#8a4d00";
if (lockedNpub) setRealOutput("author-npub-output", lockedNpub);
else setEmptyOutput("author-npub-output", "not_generated");
return;
}
if (identityMode === "anon" && !getStoredAnonSecret()) {
setLocalizedText("identity-state", "no_anon_npub");
state.style.color = "#8a4d00";
setEmptyOutput("author-npub-output", "not_generated");
return;
}
const identity = getOrCreateAnonIdentity(false);
setLocalizedText("identity-state", "anon_npub_ready");
const id = activeIdentity();
const key = id.mode === "nip07" ? "signer_nip07"
: id.mode === "nsec" ? "signer_nsec"
: "anon_npub_ready";
setLocalizedText("identity-state", key);
state.style.color = "#1d6b3a";
setRealOutput("author-npub-output", identity.npub);
setRealOutput("author-npub-output", id.npub);
updatePosterAddressOutputs();
} catch (error) {
state.textContent = error.message;
@@ -1672,7 +2192,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
if (!naddrOutput || !posterUrlOutput) return null;
try {
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const identity = activeIdentity();
const payload = buildPayloadFromForm();
const poster = withPosterUrl(payload, identity, relays);
setRealOutput("naddr-output", poster.naddr);
@@ -1684,14 +2204,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui
}
async function signAndPublish(payload, relays) {
const identity = getOrCreateAnonIdentity(false);
const identity = activeIdentity();
const poster = withPosterUrl(payload, identity, relays);
const signed = nostrTools().finalizeEvent(buildUnsignedEvent(poster.payload), identity.secretKey);
const signed = await signActiveEvent(buildUnsignedEvent(poster.payload));
setRealOutput("author-npub-output", identity.npub);
setRealOutput("naddr-output", poster.naddr);
setRealOutput("poster-url-output", poster.posterUrl);
refreshIdentityState();
if (!signed.id || !signed.sig) {
if (!signed || !signed.id || !signed.sig) {
throw new Error("Signer returned an event without id/sig.");
}
const results = await publishEvent(signed, relays);
@@ -2249,7 +2769,11 @@ Join us in a revolution that values truth and transparency. Together, we can bui
el("generate-anon-identity").addEventListener("click", () => {
try {
const identity = getOrCreateAnonIdentity(true);
identityMode = "anon";
nip07Pubkey = null;
persistMode("anon");
setRealOutput("author-npub-output", identity.npub);
refreshSignerState();
refreshIdentityState();
el("publish-status").textContent = `${nostrLabel("npub_generated", currentUiLang())} ${identity.npub}`;
} catch (error) {
@@ -2257,6 +2781,31 @@ Join us in a revolution that values truth and transparency. Together, we can bui
}
});
// Identity mode controls.
el("connect-nip07").addEventListener("click", () => {
connectNip07Identity().catch(() => setIdentityError("nip07_missing"));
});
el("use-nsec-toggle").addEventListener("click", toggleNsecImport);
el("nsec-remember").addEventListener("change", () => {
const pin = el("nsec-pin");
if (pin) pin.hidden = !el("nsec-remember").checked;
});
el("nsec-submit").addEventListener("click", () => {
submitNsecImport().catch(() => setIdentityError("invalid_nsec"));
});
el("nsec-input").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); el("nsec-submit").click(); }
});
el("unlock-key").addEventListener("click", () => {
unlockIdentity().catch(() => setIdentityError("pin_wrong"));
});
el("unlock-pin").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); el("unlock-key").click(); }
});
el("forget-key").addEventListener("click", forgetStoredIdentity);
restoreIdentity();
el("preview-editor").addEventListener("click", () => {
try {
formToPreview();
@@ -2270,7 +2819,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
try {
const payload = buildPayloadFromForm();
const relays = parseRelays(el("editor-relays").value);
const identity = getOrCreateAnonIdentity(false);
const identity = activeIdentity();
const poster = withPosterUrl(payload, identity, relays);
const event = buildUnsignedEvent(poster.payload);
setRealOutput("author-npub-output", identity.npub);