diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 9d5b329..f41a64f 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -574,6 +574,53 @@ test.describe("VoxVera static client", () => { // Publishing routes through window.nostr.signEvent and still shows the modal. await page.locator("#publish-event").click(); await expect(page.locator("#publish-modal")).toBeVisible(); + // The poster naddr must encode the SAME pubkey that signed (not the anon key). + const url = await page.locator("#publish-modal-url").inputValue(); + const naddrPk = await page.evaluate( + (u) => window.NostrTools.nip19.decode(u.split("#")[1]).data.pubkey, + url + ); + expect(naddrPk).toBe(PK); + }); + + test("NIP-07 publish builds the naddr from the signer even before the pubkey loads", async ({ page }) => { + // Regression: a flyer signed via NIP-07 had its poster naddr built from the + // anonymous key whenever window.nostr.getPublicKey hadn't resolved yet (e.g. + // right after a reload restored nip07 mode) — so the poster URL pointed at + // the wrong author and would not load back. The naddr must match the signer. + const PK = "b".repeat(64); + await page.addInitScript((pk) => { + // nip07 mode persisted (as after a reload), and getPublicKey is slow, so at + // publish time the pubkey is not yet cached — the old bug's trigger. + localStorage.setItem("voxvera_identity_mode", "nip07"); + window.nostr = { + getPublicKey: () => new Promise((res) => setTimeout(() => res(pk), 400)), + 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) { + setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify(["OK", m[1].id, true, ""]) }), 1); + } + } catch (_) {} + } + close() {} + } + window.WebSocket = FakeWS; + }, PK); + await page.goto("/#editor"); + // Publish right away, while getPublicKey is still pending (pubkey not cached). + await page.locator("#publish-event").click(); + await expect(page.locator("#publish-modal")).toBeVisible(); + const url = await page.locator("#publish-modal-url").inputValue(); + const naddrPk = await page.evaluate( + (u) => window.NostrTools.nip19.decode(u.split("#")[1]).data.pubkey, + url + ); + expect(naddrPk).toBe(PK); }); test("editor imports an nsec for the session without storing the secret", async ({ page }) => { diff --git a/site/nostr-client.js b/site/nostr-client.js index d4c758e..22ce9db 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -1741,14 +1741,42 @@ Join us in a revolution that values truth and transparency. Together, we can bui return { mode: "anon", pubkey: anon.pubkey, npub: anon.npub }; } + // Resolve the concrete pubkey the active identity will sign with. For NIP-07 + // this fetches the extension's pubkey if it has not loaded yet, so the poster + // naddr is always built from the SAME key that signs (rather than silently + // falling back to the anonymous key — which produced flyers whose naddr + // pointed at the wrong author). + async function resolveActivePubkey() { + if (identityMode === "nip07") { + if (!nip07Pubkey) { + if (!(window.nostr && typeof window.nostr.getPublicKey === "function")) { + throw new Error(nostrLabel("nip07_missing", currentUiLang())); + } + const pk = await window.nostr.getPublicKey(); + if (!/^[0-9a-f]{64}$/i.test(String(pk || ""))) { + throw new Error(nostrLabel("nip07_missing", currentUiLang())); + } + nip07Pubkey = String(pk).toLowerCase(); + } + return nip07Pubkey; + } + if (identityMode === "nsec") { + if (!importedPubkey) throw new Error(nostrLabel("identity_locked", currentUiLang())); + return importedPubkey; + } + return getOrCreateAnonIdentity(false).pubkey; + } + // Sign an unsigned event with the active identity (extension or local key). - async function signActiveEvent(unsigned) { + // pubkeyHex is the resolved signing key, used so the NIP-07 event carries the + // same pubkey the naddr was built from. + async function signActiveEvent(unsigned, pubkeyHex) { 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 }); + return await window.nostr.signEvent({ ...unsigned, pubkey: pubkeyHex || nip07Pubkey }); } if (identityMode === "nsec") { if (!importedSecretHex) throw new Error(nostrLabel("identity_locked", currentUiLang())); @@ -2225,16 +2253,24 @@ Join us in a revolution that values truth and transparency. Together, we can bui } async function signAndPublish(payload, relays) { - const identity = activeIdentity(); + // Resolve the exact signing pubkey FIRST, then build the poster naddr from + // it, so the naddr can never point at a different author than the signer. + const pubkey = await resolveActivePubkey(); + const identity = { pubkey, npub: npubFromHex(pubkey) }; const poster = withPosterUrl(payload, identity, relays); - const signed = await signActiveEvent(buildUnsignedEvent(poster.payload)); + const signed = await signActiveEvent(buildUnsignedEvent(poster.payload), pubkey); + if (!signed || !signed.id || !signed.sig) { + throw new Error("Signer returned an event without id/sig."); + } + // Guard against a signer that used a different key than the naddr was built + // from — publishing that would create an unresolvable poster URL. + if (String(signed.pubkey || "").toLowerCase() !== pubkey.toLowerCase()) { + throw new Error("Signer key does not match the poster address; not publishing."); + } setRealOutput("author-npub-output", identity.npub); setRealOutput("naddr-output", poster.naddr); setRealOutput("poster-url-output", poster.posterUrl); refreshIdentityState(); - if (!signed || !signed.id || !signed.sig) { - throw new Error("Signer returned an event without id/sig."); - } const results = await publishEvent(signed, relays); return { event: signed, results, naddr: poster.naddr, posterUrl: poster.posterUrl, payload: poster.payload }; }