mirror of
https://github.com/PR0M3TH3AN/VoxVera.git
synced 2026-10-27 09:40:40 +00:00
Add NIP-46 remote signer (bunker) as an editor identity
The editor can now sign flyers via a NIP-46 remote signer: paste a bunker:// link and the user's secret stays in their signer app (nsec.app, Amber, …) while the browser holds only an ephemeral local key. The vendored nostr-tools bundle has no nip46 module, so the client is hand-rolled over a raw WebSocket — kind-24133 JSON-RPC encrypted with NIP-44 (with a NIP-04 decrypt fallback), running connect → get_public_key → sign_event. Connections are session-only; the bunker token is never written to disk, so a reload returns to anonymous. Switching to/from any other identity mode disconnects the signer, and a successful connection mirrors the user pubkey to the board's connected key. Adds 9 localized strings across all 14 languages (i18n parity test passes). Tested with a fake relay/signer doing real NIP-44 crypto: full connect → get_public_key → sign_event → publish round-trip, across all 6 engines. Full suite 312 passing. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
bf90f1608b
commit
a4cb8dffcb
7 files changed
+490
-19
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-10
@@ -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**
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<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>
|
||||
<button type="button" id="use-nip46-toggle" class="secondary-button">Remote signer (NIP-46)</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. -->
|
||||
@@ -63,6 +64,15 @@
|
||||
<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>
|
||||
<!-- Connect a NIP-46 remote signer (bunker). The local key is
|
||||
ephemeral and in-memory only; the user's secret stays in their
|
||||
signer app. Session-only: not persisted across reloads. -->
|
||||
<div id="nip46-import" class="identity-import" hidden>
|
||||
<span id="nip46-hint" class="board-key-note" data-i18n-key="nip46_hint">Paste a bunker:// link from your signer app.</span>
|
||||
<input id="nip46-input" type="text" autocomplete="off" spellcheck="false" placeholder="bunker://…">
|
||||
<button type="button" id="nip46-submit" class="secondary-button">Connect</button>
|
||||
<span id="nip46-status" class="board-key-note" role="status"></span>
|
||||
</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>
|
||||
|
||||
@@ -225,7 +225,8 @@ main {
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
.identity-import input[type="password"] {
|
||||
.identity-import input[type="password"],
|
||||
.identity-import input[type="text"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
|
||||
+369
-2
@@ -25,6 +25,12 @@
|
||||
const UNLOCK_COOLDOWN_MS = 30000;
|
||||
const IDENTITY_NPUB_STORAGE_KEY = "voxvera_identity_npub";
|
||||
const PBKDF2_ITERATIONS = 600000;
|
||||
// NIP-46 remote signer (bunker) kind + timeouts. The connect/sign waits are
|
||||
// long because they require a human to approve the request in the signer app.
|
||||
const NIP46_KIND = 24133;
|
||||
const NIP46_RELAY_TIMEOUT_MS = 12000;
|
||||
const NIP46_CONNECT_TIMEOUT_MS = 90000;
|
||||
const NIP46_SIGN_TIMEOUT_MS = 90000;
|
||||
const UI_LANG_STORAGE_KEY = "voxvera_nostr_lang";
|
||||
const LOCALES = window.VoxVeraLocales || {};
|
||||
const FALLBACK_LANG = "en";
|
||||
@@ -124,7 +130,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
"generate-anon-identity": "generate_npub",
|
||||
"connect-nip07": "connect_extension",
|
||||
"use-nsec-toggle": "use_nsec",
|
||||
"use-nip46-toggle": "use_nip46",
|
||||
"nsec-submit": "use_key",
|
||||
"nip46-submit": "connect_remote",
|
||||
"unlock-key": "unlock",
|
||||
"forget-key": "forget_key",
|
||||
"viewer-fetch-render": "fetch_render",
|
||||
@@ -243,6 +251,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "هذا الحقل طويل جدًا على الملصق القابل للطباعة:",
|
||||
relays_accepted: "المرحلات التي قبلت",
|
||||
too_many_attempts: "محاولات كثيرة جدًا — انتظر لحظة وحاول مرة أخرى.",
|
||||
use_nip46: "موقّع عن بُعد (NIP-46)",
|
||||
connect_remote: "اتصال",
|
||||
signer_nip46: "النشر عبر موقّع عن بُعد",
|
||||
nip46_hint: "الصق رابط bunker:// من تطبيق التوقيع لديك (مثل nsec.app أو Amber).",
|
||||
nip46_connecting: "جارٍ الاتصال — وافق على الطلب في تطبيق التوقيع…",
|
||||
nip46_connected: "تم الاتصال بالموقّع عن بُعد.",
|
||||
nip46_bad_uri: "لا يبدو هذا رابط bunker://.",
|
||||
nip46_timeout: "لم يستجب الموقّع عن بُعد. افتح التطبيق وحاول مرة أخرى.",
|
||||
nip46_failed: "تعذّر الاتصال بالموقّع عن بُعد.",
|
||||
no_anon_npub: "لا يوجد npub مجهول بعد",
|
||||
anon_npub_ready: "npub مجهول جاهز",
|
||||
npub_generated: "تم إنشاء npub مجهول:",
|
||||
@@ -336,6 +353,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Dieses Feld ist zu lang für das druckbare Plakat:",
|
||||
relays_accepted: "Relays akzeptiert",
|
||||
too_many_attempts: "Zu viele Versuche – warte einen Moment und versuche es erneut.",
|
||||
use_nip46: "Externer Signierer (NIP-46)",
|
||||
connect_remote: "Verbinden",
|
||||
signer_nip46: "Veröffentlichung über externen Signierer",
|
||||
nip46_hint: "Füge einen bunker://-Link aus deiner Signier-App ein (z. B. nsec.app, Amber).",
|
||||
nip46_connecting: "Verbinde – bestätige die Anfrage in deiner Signier-App…",
|
||||
nip46_connected: "Externer Signierer verbunden.",
|
||||
nip46_bad_uri: "Das sieht nicht nach einem bunker://-Link aus.",
|
||||
nip46_timeout: "Der externe Signierer hat nicht geantwortet. Öffne die App und versuche es erneut.",
|
||||
nip46_failed: "Verbindung zum externen Signierer fehlgeschlagen.",
|
||||
no_anon_npub: "Noch kein anonymes npub",
|
||||
anon_npub_ready: "Anonymes npub bereit",
|
||||
npub_generated: "Anonymes npub erzeugt:",
|
||||
@@ -451,7 +477,16 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
connection_error: "connection error",
|
||||
field_too_long: "That field is too long for the printable flyer:",
|
||||
relays_accepted: "Relays accepted",
|
||||
too_many_attempts: "Too many attempts — wait a moment and try again."
|
||||
too_many_attempts: "Too many attempts — wait a moment and try again.",
|
||||
use_nip46: "Remote signer (NIP-46)",
|
||||
connect_remote: "Connect",
|
||||
signer_nip46: "Publishing via remote signer",
|
||||
nip46_hint: "Paste a bunker:// link from your signer app (e.g. nsec.app, Amber).",
|
||||
nip46_connecting: "Connecting — approve the request in your signer app…",
|
||||
nip46_connected: "Remote signer connected.",
|
||||
nip46_bad_uri: "That doesn't look like a bunker:// link.",
|
||||
nip46_timeout: "The remote signer didn't respond. Open your signer app and try again.",
|
||||
nip46_failed: "Couldn't connect to the remote signer.",
|
||||
},
|
||||
es: {
|
||||
back: "Volver",
|
||||
@@ -522,6 +557,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Ese campo es demasiado largo para el cartel imprimible:",
|
||||
relays_accepted: "Relés que aceptaron",
|
||||
too_many_attempts: "Demasiados intentos: espera un momento e inténtalo de nuevo.",
|
||||
use_nip46: "Firmante remoto (NIP-46)",
|
||||
connect_remote: "Conectar",
|
||||
signer_nip46: "Publicando mediante firmante remoto",
|
||||
nip46_hint: "Pega un enlace bunker:// de tu app de firma (p. ej. nsec.app, Amber).",
|
||||
nip46_connecting: "Conectando: aprueba la solicitud en tu app de firma…",
|
||||
nip46_connected: "Firmante remoto conectado.",
|
||||
nip46_bad_uri: "Esto no parece un enlace bunker://.",
|
||||
nip46_timeout: "El firmante remoto no respondió. Abre la app e inténtalo de nuevo.",
|
||||
nip46_failed: "No se pudo conectar con el firmante remoto.",
|
||||
no_anon_npub: "Aún no hay npub anónimo",
|
||||
anon_npub_ready: "npub anónimo listo",
|
||||
npub_generated: "npub anónimo generado:",
|
||||
@@ -615,6 +659,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "این فیلد برای پوستر قابل چاپ خیلی طولانی است:",
|
||||
relays_accepted: "رلههایی که پذیرفتند",
|
||||
too_many_attempts: "تلاشهای بیش از حد — کمی صبر کنید و دوباره تلاش کنید.",
|
||||
use_nip46: "امضاکننده از راه دور (NIP-46)",
|
||||
connect_remote: "اتصال",
|
||||
signer_nip46: "انتشار از طریق امضاکننده از راه دور",
|
||||
nip46_hint: "پیوند bunker:// را از برنامه امضای خود وارد کنید (مثل nsec.app یا Amber).",
|
||||
nip46_connecting: "در حال اتصال — درخواست را در برنامه امضا تأیید کنید…",
|
||||
nip46_connected: "امضاکننده از راه دور متصل شد.",
|
||||
nip46_bad_uri: "این یک پیوند bunker:// به نظر نمیرسد.",
|
||||
nip46_timeout: "امضاکننده از راه دور پاسخ نداد. برنامه را باز کنید و دوباره تلاش کنید.",
|
||||
nip46_failed: "اتصال به امضاکننده از راه دور ممکن نشد.",
|
||||
no_anon_npub: "هنوز npub ناشناس وجود ندارد",
|
||||
anon_npub_ready: "npub ناشناس آماده است",
|
||||
npub_generated: "npub ناشناس ساخته شد:",
|
||||
@@ -708,6 +761,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Ce champ est trop long pour l'affiche imprimable :",
|
||||
relays_accepted: "Relais ayant accepté",
|
||||
too_many_attempts: "Trop de tentatives — patientez un instant et réessayez.",
|
||||
use_nip46: "Signataire distant (NIP-46)",
|
||||
connect_remote: "Connecter",
|
||||
signer_nip46: "Publication via un signataire distant",
|
||||
nip46_hint: "Collez un lien bunker:// depuis votre app de signature (ex. nsec.app, Amber).",
|
||||
nip46_connecting: "Connexion — approuvez la demande dans votre app de signature…",
|
||||
nip46_connected: "Signataire distant connecté.",
|
||||
nip46_bad_uri: "Cela ne ressemble pas à un lien bunker://.",
|
||||
nip46_timeout: "Le signataire distant n'a pas répondu. Ouvrez l'app et réessayez.",
|
||||
nip46_failed: "Impossible de se connecter au signataire distant.",
|
||||
no_anon_npub: "Aucun npub anonyme pour le moment",
|
||||
anon_npub_ready: "npub anonyme pret",
|
||||
npub_generated: "npub anonyme genere :",
|
||||
@@ -801,6 +863,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "השדה הזה ארוך מדי עבור הכרזה הניתנת להדפסה:",
|
||||
relays_accepted: "ממסרים שקיבלו",
|
||||
too_many_attempts: "יותר מדי ניסיונות — המתן רגע ונסה שוב.",
|
||||
use_nip46: "חותם מרוחק (NIP-46)",
|
||||
connect_remote: "התחבר",
|
||||
signer_nip46: "פרסום באמצעות חותם מרוחק",
|
||||
nip46_hint: "הדבק קישור bunker:// מאפליקציית החתימה שלך (למשל nsec.app או Amber).",
|
||||
nip46_connecting: "מתחבר — אשר את הבקשה באפליקציית החתימה…",
|
||||
nip46_connected: "החותם המרוחק מחובר.",
|
||||
nip46_bad_uri: "זה לא נראה כמו קישור bunker://.",
|
||||
nip46_timeout: "החותם המרוחק לא הגיב. פתח את האפליקציה ונסה שוב.",
|
||||
nip46_failed: "לא ניתן להתחבר לחותם המרוחק.",
|
||||
no_anon_npub: "עדיין אין npub אנונימי",
|
||||
anon_npub_ready: "npub אנונימי מוכן",
|
||||
npub_generated: "npub אנונימי נוצר:",
|
||||
@@ -894,6 +965,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "यह फ़ील्ड प्रिंट करने योग्य पोस्टर के लिए बहुत लंबा है:",
|
||||
relays_accepted: "स्वीकृत रिले",
|
||||
too_many_attempts: "बहुत अधिक प्रयास — थोड़ी देर रुकें और पुनः प्रयास करें।",
|
||||
use_nip46: "रिमोट साइनर (NIP-46)",
|
||||
connect_remote: "कनेक्ट करें",
|
||||
signer_nip46: "रिमोट साइनर के माध्यम से प्रकाशन",
|
||||
nip46_hint: "अपने साइनर ऐप से bunker:// लिंक पेस्ट करें (जैसे nsec.app, Amber)।",
|
||||
nip46_connecting: "कनेक्ट हो रहा है — अपने साइनर ऐप में अनुरोध स्वीकृत करें…",
|
||||
nip46_connected: "रिमोट साइनर कनेक्ट हो गया।",
|
||||
nip46_bad_uri: "यह bunker:// लिंक जैसा नहीं लगता।",
|
||||
nip46_timeout: "रिमोट साइनर ने उत्तर नहीं दिया। ऐप खोलें और पुनः प्रयास करें।",
|
||||
nip46_failed: "रिमोट साइनर से कनेक्ट नहीं हो सका।",
|
||||
no_anon_npub: "अभी कोई अनाम npub नहीं",
|
||||
anon_npub_ready: "अनाम npub तैयार",
|
||||
npub_generated: "अनाम npub बनाया गया:",
|
||||
@@ -987,6 +1067,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "この項目は印刷用ちらしには長すぎます:",
|
||||
relays_accepted: "受け入れたリレー",
|
||||
too_many_attempts: "試行回数が多すぎます。少し待ってからもう一度お試しください。",
|
||||
use_nip46: "リモート署名(NIP-46)",
|
||||
connect_remote: "接続",
|
||||
signer_nip46: "リモート署名で公開",
|
||||
nip46_hint: "署名アプリの bunker:// リンクを貼り付けてください(例:nsec.app、Amber)。",
|
||||
nip46_connecting: "接続中 — 署名アプリでリクエストを承認してください…",
|
||||
nip46_connected: "リモート署名に接続しました。",
|
||||
nip46_bad_uri: "これは bunker:// リンクではないようです。",
|
||||
nip46_timeout: "リモート署名が応答しませんでした。アプリを開いて再試行してください。",
|
||||
nip46_failed: "リモート署名に接続できませんでした。",
|
||||
no_anon_npub: "匿名npubはまだありません",
|
||||
anon_npub_ready: "匿名npub準備完了",
|
||||
npub_generated: "匿名npubを生成しました:",
|
||||
@@ -1080,6 +1169,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Esse campo é longo demais para o panfleto imprimível:",
|
||||
relays_accepted: "Relés que aceitaram",
|
||||
too_many_attempts: "Tentativas em excesso — aguarde um momento e tente novamente.",
|
||||
use_nip46: "Assinador remoto (NIP-46)",
|
||||
connect_remote: "Conectar",
|
||||
signer_nip46: "Publicando via assinador remoto",
|
||||
nip46_hint: "Cole um link bunker:// do seu app de assinatura (ex.: nsec.app, Amber).",
|
||||
nip46_connecting: "Conectando — aprove a solicitação no seu app de assinatura…",
|
||||
nip46_connected: "Assinador remoto conectado.",
|
||||
nip46_bad_uri: "Isso não parece um link bunker://.",
|
||||
nip46_timeout: "O assinador remoto não respondeu. Abra o app e tente novamente.",
|
||||
nip46_failed: "Não foi possível conectar ao assinador remoto.",
|
||||
no_anon_npub: "Ainda nao ha npub anonimo",
|
||||
anon_npub_ready: "npub anonimo pronto",
|
||||
npub_generated: "npub anonimo gerado:",
|
||||
@@ -1173,6 +1271,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Это поле слишком длинное для печатной листовки:",
|
||||
relays_accepted: "Реле приняли",
|
||||
too_many_attempts: "Слишком много попыток — подождите немного и попробуйте снова.",
|
||||
use_nip46: "Удалённый подписант (NIP-46)",
|
||||
connect_remote: "Подключить",
|
||||
signer_nip46: "Публикация через удалённый подписант",
|
||||
nip46_hint: "Вставьте ссылку bunker:// из вашего приложения для подписи (например, nsec.app, Amber).",
|
||||
nip46_connecting: "Подключение — подтвердите запрос в приложении для подписи…",
|
||||
nip46_connected: "Удалённый подписант подключён.",
|
||||
nip46_bad_uri: "Это не похоже на ссылку bunker://.",
|
||||
nip46_timeout: "Удалённый подписант не ответил. Откройте приложение и попробуйте снова.",
|
||||
nip46_failed: "Не удалось подключиться к удалённому подписанту.",
|
||||
no_anon_npub: "Анонимного npub пока нет",
|
||||
anon_npub_ready: "Анонимный npub готов",
|
||||
npub_generated: "Анонимный npub создан:",
|
||||
@@ -1266,6 +1373,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Sehemu hii ni ndefu mno kwa bango linaloweza kuchapishwa:",
|
||||
relays_accepted: "Relay zilizokubali",
|
||||
too_many_attempts: "Majaribio mengi mno — subiri kidogo kisha ujaribu tena.",
|
||||
use_nip46: "Mtia-saini wa mbali (NIP-46)",
|
||||
connect_remote: "Unganisha",
|
||||
signer_nip46: "Inachapisha kupitia mtia-saini wa mbali",
|
||||
nip46_hint: "Bandika kiungo cha bunker:// kutoka programu yako ya kutia saini (k.m. nsec.app, Amber).",
|
||||
nip46_connecting: "Inaunganisha — idhinisha ombi katika programu yako ya kutia saini…",
|
||||
nip46_connected: "Mtia-saini wa mbali ameunganishwa.",
|
||||
nip46_bad_uri: "Hiki hakionekani kama kiungo cha bunker://.",
|
||||
nip46_timeout: "Mtia-saini wa mbali hakujibu. Fungua programu na ujaribu tena.",
|
||||
nip46_failed: "Imeshindwa kuunganisha na mtia-saini wa mbali.",
|
||||
no_anon_npub: "Hakuna npub isiyojulikana bado",
|
||||
anon_npub_ready: "npub isiyojulikana iko tayari",
|
||||
npub_generated: "npub isiyojulikana imetengenezwa:",
|
||||
@@ -1359,6 +1475,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "Bu alan yazdırılabilir el ilanı için çok uzun:",
|
||||
relays_accepted: "Kabul eden röleler",
|
||||
too_many_attempts: "Çok fazla deneme — biraz bekleyip tekrar deneyin.",
|
||||
use_nip46: "Uzak imzalayıcı (NIP-46)",
|
||||
connect_remote: "Bağlan",
|
||||
signer_nip46: "Uzak imzalayıcı ile yayımlanıyor",
|
||||
nip46_hint: "İmzalama uygulamanızdan bir bunker:// bağlantısı yapıştırın (ör. nsec.app, Amber).",
|
||||
nip46_connecting: "Bağlanıyor — isteği imzalama uygulamanızda onaylayın…",
|
||||
nip46_connected: "Uzak imzalayıcı bağlandı.",
|
||||
nip46_bad_uri: "Bu bir bunker:// bağlantısına benzemiyor.",
|
||||
nip46_timeout: "Uzak imzalayıcı yanıt vermedi. Uygulamayı açıp tekrar deneyin.",
|
||||
nip46_failed: "Uzak imzalayıcıya bağlanılamadı.",
|
||||
no_anon_npub: "Henüz anonim npub yok",
|
||||
anon_npub_ready: "Anonim npub hazır",
|
||||
npub_generated: "Anonim npub oluşturuldu:",
|
||||
@@ -1452,6 +1577,15 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
field_too_long: "该字段对于可打印传单来说太长了:",
|
||||
relays_accepted: "已接受的中继",
|
||||
too_many_attempts: "尝试次数过多——请稍候再试。",
|
||||
use_nip46: "远程签名器(NIP-46)",
|
||||
connect_remote: "连接",
|
||||
signer_nip46: "通过远程签名器发布",
|
||||
nip46_hint: "粘贴来自签名应用的 bunker:// 链接(如 nsec.app、Amber)。",
|
||||
nip46_connecting: "正在连接——请在签名应用中批准请求…",
|
||||
nip46_connected: "远程签名器已连接。",
|
||||
nip46_bad_uri: "这看起来不是 bunker:// 链接。",
|
||||
nip46_timeout: "远程签名器无响应。请打开应用并重试。",
|
||||
nip46_failed: "无法连接到远程签名器。",
|
||||
no_anon_npub: "还没有匿名 npub",
|
||||
anon_npub_ready: "匿名 npub 已就绪",
|
||||
npub_generated: "已生成匿名 npub:",
|
||||
@@ -1894,6 +2028,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
if (!state) return;
|
||||
const key = identityMode === "nip07" ? "signer_nip07"
|
||||
: identityMode === "nsec" ? "signer_nsec"
|
||||
: identityMode === "nip46" ? "signer_nip46"
|
||||
: "signer_anon";
|
||||
setLocalizedText("signer-state", key);
|
||||
state.style.color = "#1d6b3a";
|
||||
@@ -1953,11 +2088,13 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
// 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 identityMode = "anon"; // "anon" | "nip07" | "nsec" | "nip46"
|
||||
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
|
||||
let nip46Pubkey = null; // hex user pubkey, when mode === "nip46"
|
||||
let nip46Signer = null; // live remote-signer connection, when mode === "nip46"
|
||||
|
||||
function npubFromHex(pubkeyHex) {
|
||||
try { return nostrTools().nip19.npubEncode(pubkeyHex); } catch (_) { return pubkeyHex; }
|
||||
@@ -1972,6 +2109,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
if (identityMode === "nsec" && importedPubkey) {
|
||||
return { mode: "nsec", pubkey: importedPubkey, npub: npubFromHex(importedPubkey) };
|
||||
}
|
||||
if (identityMode === "nip46" && nip46Pubkey) {
|
||||
return { mode: "nip46", pubkey: nip46Pubkey, npub: npubFromHex(nip46Pubkey) };
|
||||
}
|
||||
const anon = getOrCreateAnonIdentity(false);
|
||||
return { mode: "anon", pubkey: anon.pubkey, npub: anon.npub };
|
||||
}
|
||||
@@ -1999,6 +2139,10 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
if (!importedPubkey) throw new Error(nostrLabel("identity_locked", currentUiLang()));
|
||||
return importedPubkey;
|
||||
}
|
||||
if (identityMode === "nip46") {
|
||||
if (!nip46Signer || !nip46Pubkey) throw new Error(nostrLabel("nip46_failed", currentUiLang()));
|
||||
return nip46Pubkey;
|
||||
}
|
||||
return getOrCreateAnonIdentity(false).pubkey;
|
||||
}
|
||||
|
||||
@@ -2017,6 +2161,18 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
if (!importedSecretHex) throw new Error(nostrLabel("identity_locked", currentUiLang()));
|
||||
return tools.finalizeEvent(unsigned, hexToBytes(importedSecretHex));
|
||||
}
|
||||
if (identityMode === "nip46") {
|
||||
if (!nip46Signer) throw new Error(nostrLabel("nip46_failed", currentUiLang()));
|
||||
// The remote signer signs as the user's key; ask it to sign the template
|
||||
// (carrying the resolved pubkey so it matches the naddr we built).
|
||||
const toSign = { ...unsigned, pubkey: pubkeyHex || nip46Pubkey };
|
||||
const res = await nip46Signer.request("sign_event", [JSON.stringify(toSign)], NIP46_SIGN_TIMEOUT_MS);
|
||||
let signed;
|
||||
try { signed = typeof res === "string" ? JSON.parse(res) : res; } catch (_) {
|
||||
throw new Error(nostrLabel("nip46_failed", currentUiLang()));
|
||||
}
|
||||
return signed;
|
||||
}
|
||||
return tools.finalizeEvent(unsigned, getOrCreateAnonIdentity(false).secretKey);
|
||||
}
|
||||
|
||||
@@ -2107,13 +2263,211 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
if (row) row.hidden = !show;
|
||||
}
|
||||
|
||||
// --- NIP-46 remote signer (bunker) -----------------------------------------
|
||||
// We hand-roll a minimal NIP-46 client over a raw WebSocket (the vendored
|
||||
// nostr-tools bundle ships nip04/nip44 + key helpers but no nip46 module).
|
||||
// The local key is ephemeral and lives only in memory: the user's real secret
|
||||
// never reaches the browser — the remote signer holds it and signs on request.
|
||||
|
||||
// bunker://<remote-signer-pubkey-hex>?relay=wss://...&secret=<token>
|
||||
function parseBunkerUri(uri) {
|
||||
const text = String(uri || "").trim();
|
||||
const match = text.match(/^bunker:\/\/([0-9a-f]{64})\??([\s\S]*)$/i);
|
||||
if (!match) return null;
|
||||
const remotePubkey = match[1].toLowerCase();
|
||||
let params;
|
||||
try { params = new URLSearchParams(match[2] || ""); } catch (_) { return null; }
|
||||
const relays = params.getAll("relay").filter((r) => /^wss?:\/\//i.test(r));
|
||||
if (!relays.length) return null;
|
||||
return { remotePubkey, relays, secret: params.get("secret") || "" };
|
||||
}
|
||||
|
||||
// Encrypt/decrypt the JSON-RPC payload. NIP-46 moved from NIP-04 to NIP-44, so
|
||||
// we encrypt requests with NIP-44 (modern signers) but accept either scheme on
|
||||
// responses for backward compatibility with older signers.
|
||||
function nip46Codec(localSkHex, remotePubkey) {
|
||||
const tools = nostrTools();
|
||||
let convKey = null;
|
||||
try { convKey = tools.nip44.getConversationKey(hexToBytes(localSkHex), remotePubkey); } catch (_) {}
|
||||
return {
|
||||
encrypt(text) {
|
||||
if (convKey) return tools.nip44.encrypt(text, convKey);
|
||||
return tools.nip04.encrypt(localSkHex, remotePubkey, text);
|
||||
},
|
||||
decrypt(ciphertext) {
|
||||
if (convKey) {
|
||||
try { return tools.nip44.decrypt(ciphertext, convKey); } catch (_) {}
|
||||
}
|
||||
return tools.nip04.decrypt(localSkHex, remotePubkey, ciphertext);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function openNip46Connection({ relay, remotePubkey, localSkHex, localPubkey }) {
|
||||
const tools = nostrTools();
|
||||
const codec = nip46Codec(localSkHex, remotePubkey);
|
||||
const subId = "voxvera46-" + Math.random().toString(36).slice(2);
|
||||
const pending = new Map(); // request id -> { resolve, reject, timer }
|
||||
let ws = null;
|
||||
let closed = false;
|
||||
|
||||
function rejectAll(err) {
|
||||
pending.forEach(({ reject, timer }) => { clearTimeout(timer); reject(err); });
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
function handleEvent(ev) {
|
||||
if (!ev || ev.kind !== NIP46_KIND || ev.pubkey !== remotePubkey) return;
|
||||
let text;
|
||||
try { text = codec.decrypt(ev.content); } catch (_) { return; }
|
||||
let msg;
|
||||
try { msg = JSON.parse(text); } catch (_) { return; }
|
||||
if (!msg || !msg.id) return;
|
||||
const slot = pending.get(msg.id);
|
||||
if (!slot) return;
|
||||
// Some signers (e.g. nsec.app) first reply asking the user to approve via
|
||||
// a URL, then send the real result with the same id. Open it and wait.
|
||||
if (msg.result === "auth_url") {
|
||||
const url = msg.error || "";
|
||||
if (/^https?:\/\//i.test(url)) { try { window.open(url, "_blank", "noopener"); } catch (_) {} }
|
||||
return;
|
||||
}
|
||||
pending.delete(msg.id);
|
||||
clearTimeout(slot.timer);
|
||||
if (msg.error) slot.reject(new Error(String(msg.error)));
|
||||
else slot.resolve(msg.result);
|
||||
}
|
||||
|
||||
function open() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try { ws = new WebSocket(relay); } catch (e) { reject(e); return; }
|
||||
const openTimer = setTimeout(() => {
|
||||
try { ws.close(); } catch (_) {}
|
||||
reject(new Error("relay connect timeout"));
|
||||
}, NIP46_RELAY_TIMEOUT_MS);
|
||||
ws.onopen = () => {
|
||||
clearTimeout(openTimer);
|
||||
const since = Math.floor(Date.now() / 1000) - 10;
|
||||
ws.send(JSON.stringify(["REQ", subId, { kinds: [NIP46_KIND], "#p": [localPubkey], since }]));
|
||||
resolve();
|
||||
};
|
||||
ws.onmessage = (m) => {
|
||||
let data;
|
||||
try { data = JSON.parse(m.data); } catch (_) { return; }
|
||||
if (data[0] === "EVENT" && data[1] === subId) handleEvent(data[2]);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
ws.onclose = () => { if (!closed) rejectAll(new Error("relay disconnected")); };
|
||||
});
|
||||
}
|
||||
|
||||
function request(method, params, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (closed || !ws || ws.readyState !== 1 /* WebSocket.OPEN */) {
|
||||
reject(new Error("remote signer not connected"));
|
||||
return;
|
||||
}
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
let content;
|
||||
try { content = codec.encrypt(JSON.stringify({ id, method, params })); }
|
||||
catch (e) { reject(e); return; }
|
||||
let signed;
|
||||
try {
|
||||
signed = tools.finalizeEvent({
|
||||
kind: NIP46_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", remotePubkey]],
|
||||
content
|
||||
}, hexToBytes(localSkHex));
|
||||
} catch (e) { reject(e); return; }
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(nostrLabel("nip46_timeout", currentUiLang())));
|
||||
}, timeoutMs || NIP46_SIGN_TIMEOUT_MS);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
try { ws.send(JSON.stringify(["EVENT", signed])); }
|
||||
catch (e) { pending.delete(id); clearTimeout(timer); reject(e); }
|
||||
});
|
||||
}
|
||||
|
||||
function close() {
|
||||
closed = true;
|
||||
rejectAll(new Error("disconnected"));
|
||||
try { ws && ws.close(); } catch (_) {}
|
||||
ws = null;
|
||||
}
|
||||
|
||||
return { open, request, close };
|
||||
}
|
||||
|
||||
function disconnectNip46() {
|
||||
if (nip46Signer) { try { nip46Signer.close(); } catch (_) {} }
|
||||
nip46Signer = null;
|
||||
nip46Pubkey = null;
|
||||
}
|
||||
|
||||
function toggleNip46Import() {
|
||||
setIdentityError(null);
|
||||
showLockedRow(false);
|
||||
const nsec = el("nsec-import");
|
||||
if (nsec) nsec.hidden = true;
|
||||
const row = el("nip46-import");
|
||||
if (!row) return;
|
||||
row.hidden = !row.hidden;
|
||||
if (!row.hidden) { const i = el("nip46-input"); if (i) i.focus(); }
|
||||
}
|
||||
|
||||
async function submitNip46Import() {
|
||||
setIdentityError(null);
|
||||
const input = el("nip46-input");
|
||||
const parsed = parseBunkerUri(input && input.value);
|
||||
if (!parsed) { setIdentityError("nip46_bad_uri"); return; }
|
||||
const status = el("nip46-status");
|
||||
if (status) status.textContent = nostrLabel("nip46_connecting", currentUiLang());
|
||||
const tools = nostrTools();
|
||||
const skBytes = tools.generateSecretKey();
|
||||
const localSkHex = bytesToHex(skBytes);
|
||||
const localPubkey = tools.getPublicKey(skBytes);
|
||||
const conn = openNip46Connection({
|
||||
relay: parsed.relays[0], remotePubkey: parsed.remotePubkey, localSkHex, localPubkey
|
||||
});
|
||||
try {
|
||||
await conn.open();
|
||||
await conn.request("connect", [parsed.remotePubkey, parsed.secret || ""], NIP46_CONNECT_TIMEOUT_MS);
|
||||
const userPubkey = await conn.request("get_public_key", [], NIP46_CONNECT_TIMEOUT_MS);
|
||||
if (!/^[0-9a-f]{64}$/i.test(String(userPubkey || ""))) throw new Error("bad pubkey");
|
||||
disconnectNip46();
|
||||
nip46Signer = conn;
|
||||
nip46Pubkey = String(userPubkey).toLowerCase();
|
||||
identityMode = "nip46";
|
||||
if (input) input.value = "";
|
||||
if (status) status.textContent = "";
|
||||
const row = el("nip46-import");
|
||||
if (row) row.hidden = true;
|
||||
// Session-only: the bunker token is never written to disk, so a reload
|
||||
// returns cleanly to anonymous rather than a dead connection.
|
||||
persistMode("anon");
|
||||
syncConnectedPubkey(nip46Pubkey);
|
||||
refreshSignerState();
|
||||
refreshIdentityState();
|
||||
} catch (error) {
|
||||
try { conn.close(); } catch (_) {}
|
||||
if (status) status.textContent = "";
|
||||
const timedOut = /timeout/i.test(String(error && error.message));
|
||||
setIdentityError(timedOut ? "nip46_timeout" : "nip46_failed");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identity actions wired to the controls ---
|
||||
function chooseAnonIdentity() {
|
||||
disconnectNip46();
|
||||
identityMode = "anon";
|
||||
nip07Pubkey = null;
|
||||
setIdentityError(null);
|
||||
const imp = el("nsec-import");
|
||||
if (imp) imp.hidden = true;
|
||||
const remote = el("nip46-import");
|
||||
if (remote) remote.hidden = true;
|
||||
persistMode("anon");
|
||||
syncConnectedPubkey(activeIdentity().pubkey);
|
||||
refreshSignerState();
|
||||
@@ -2129,6 +2483,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
try {
|
||||
const pk = await window.nostr.getPublicKey();
|
||||
if (!/^[0-9a-f]{64}$/i.test(String(pk || ""))) throw new Error("bad pubkey");
|
||||
disconnectNip46();
|
||||
nip07Pubkey = String(pk).toLowerCase();
|
||||
identityMode = "nip07";
|
||||
const imp = el("nsec-import");
|
||||
@@ -2145,6 +2500,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
function toggleNsecImport() {
|
||||
setIdentityError(null);
|
||||
showLockedRow(false);
|
||||
const remote = el("nip46-import");
|
||||
if (remote) remote.hidden = true;
|
||||
const row = el("nsec-import");
|
||||
if (!row) return;
|
||||
row.hidden = !row.hidden;
|
||||
@@ -2181,6 +2538,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
} else {
|
||||
try { window.localStorage.removeItem(IDENTITY_ENC_STORAGE_KEY); } catch (_) {}
|
||||
}
|
||||
disconnectNip46();
|
||||
importedSecretHex = bytesToHex(secretBytes);
|
||||
importedPubkey = String(pubkey).toLowerCase();
|
||||
lockedNpub = npubFromHex(importedPubkey);
|
||||
@@ -2229,6 +2587,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
}
|
||||
unlockFails = 0;
|
||||
setUnlockLock(0);
|
||||
disconnectNip46();
|
||||
importedSecretHex = hex;
|
||||
importedPubkey = nostrTools().getPublicKey(hexToBytes(hex));
|
||||
lockedNpub = npubFromHex(importedPubkey);
|
||||
@@ -2303,6 +2662,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
const id = activeIdentity();
|
||||
const key = id.mode === "nip07" ? "signer_nip07"
|
||||
: id.mode === "nsec" ? "signer_nsec"
|
||||
: id.mode === "nip46" ? "signer_nip46"
|
||||
: "anon_npub_ready";
|
||||
setLocalizedText("identity-state", key);
|
||||
state.style.color = "#1d6b3a";
|
||||
@@ -3307,6 +3667,13 @@ Join us in a revolution that values truth and transparency. Together, we can bui
|
||||
el("nsec-input").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); el("nsec-submit").click(); }
|
||||
});
|
||||
el("use-nip46-toggle").addEventListener("click", toggleNip46Import);
|
||||
el("nip46-submit").addEventListener("click", () => {
|
||||
submitNip46Import().catch(() => setIdentityError("nip46_failed"));
|
||||
});
|
||||
el("nip46-input").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); el("nip46-submit").click(); }
|
||||
});
|
||||
el("unlock-key").addEventListener("click", () => {
|
||||
unlockIdentity().catch(() => setIdentityError("pin_wrong"));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user