diff --git a/desktop/src/shared/api/relayMeshSignaling.ts b/desktop/src/shared/api/relayMeshSignaling.ts index 906b7b1e2d..aaec24150e 100644 --- a/desktop/src/shared/api/relayMeshSignaling.ts +++ b/desktop/src/shared/api/relayMeshSignaling.ts @@ -1,6 +1,7 @@ import { signRelayEvent } from "@/shared/api/tauri"; import type { RelayEvent } from "@/shared/api/types"; import { relayClient } from "@/shared/api/relayClient"; +import { canonicalPubkeyOrThrow } from "@/shared/lib/pubkey"; import { KIND_MESH_CALL_ME_NOW, KIND_MESH_CONNECT_REQUEST, @@ -43,7 +44,7 @@ export async function publishMeshConnectRequest(input: { const event = await signRelayEvent({ kind: KIND_MESH_CONNECT_REQUEST, content: JSON.stringify(content), - tags: [["p", input.targetPubkey]], + tags: [["p", canonicalPubkeyOrThrow(input.targetPubkey)]], }); await relayClient.publishEvent( event, diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs new file mode 100644 index 0000000000..85aa823c2d --- /dev/null +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { canonicalPubkeyOrThrow } from "./pubkey.ts"; + +const HEX = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4"; +const NPUB = "npub1xhqc4cnnln86lqxk983qulu8yxusfxfhntwl75es2jkvy5zvz26qzr0685"; + +test("passes through canonical lowercase hex unchanged", () => { + assert.equal(canonicalPubkeyOrThrow(HEX), HEX); +}); + +test("lowercases uppercase hex", () => { + assert.equal(canonicalPubkeyOrThrow(HEX.toUpperCase()), HEX); +}); + +test("trims surrounding whitespace", () => { + assert.equal(canonicalPubkeyOrThrow(` ${HEX}\n`), HEX); +}); + +test("decodes npub to hex", () => { + assert.equal(canonicalPubkeyOrThrow(NPUB), HEX); +}); + +test("trims then decodes npub", () => { + assert.equal(canonicalPubkeyOrThrow(` ${NPUB} `), HEX); +}); + +test("throws on empty string", () => { + assert.throws(() => canonicalPubkeyOrThrow(""), /invalid pubkey/); +}); + +test("throws on too-short hex", () => { + assert.throws(() => canonicalPubkeyOrThrow("35c18ae2"), /invalid pubkey/); +}); + +test("throws on non-hex garbage", () => { + assert.throws(() => canonicalPubkeyOrThrow("not-a-pubkey"), /invalid pubkey/); +}); + +test("throws on malformed npub", () => { + assert.throws(() => canonicalPubkeyOrThrow("npub1zzzz")); +}); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index 072140374c..b13fae343e 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -1,3 +1,5 @@ +import { decode } from "nostr-tools/nip19"; + /** * Canonical pubkey normalisation. * @@ -7,3 +9,32 @@ export function normalizePubkey(pubkey: string): string { return pubkey.trim().toLowerCase(); } + +const HEX64 = /^[0-9a-f]{64}$/; + +/** + * Coerce a pubkey to canonical 64-char lowercase hex, accepting either hex + * (any case, padded) or `npub1…`. Throws if the result is not a valid pubkey. + * + * Use at signing boundaries where a malformed value would otherwise be signed + * into a tag and rejected downstream with an opaque error (e.g. the relay's + * "mesh connect request missing #p target" when the tag value is unusable). + * Failing here turns that into a clear, local error before the event is sent. + */ +export function canonicalPubkeyOrThrow(pubkey: string): string { + const trimmed = pubkey.trim(); + if (trimmed.startsWith("npub1")) { + const decoded = decode(trimmed); + if (decoded.type !== "npub") { + throw new Error(`invalid pubkey: ${trimmed}`); + } + return decoded.data; + } + const hex = trimmed.toLowerCase(); + if (!HEX64.test(hex)) { + throw new Error( + `invalid pubkey: expected 64-char hex or npub, got ${trimmed}`, + ); + } + return hex; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 27544bb79c..5f3b045fa9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4981,10 +4981,23 @@ function sendToMockSocket(args: { // Mesh control events (24620 status report, 24621 connect request) are not // channel messages — they carry a `p` tag, not an `h` tag. The real relay - // accepts them after membership/shape checks; the mock just ACKs so the - // desktop mesh flow (publishMeshConnectRequest) can proceed. We do not model - // the paired 24622 here; that belongs in a dedicated call-me-now test. + // accepts them after membership/shape checks; the mock mirrors the one shape + // check that matters for regression coverage: a 24621 MUST carry a `#p` + // target, else the real relay rejects it with "missing #p target". ACKing a + // tagless 24621 here would hide exactly that bug, so reject it instead. if (event.kind === 24620 || event.kind === 24621) { + if ( + event.kind === 24621 && + !event.tags.some((tag) => tag[0] === "p" && tag[1]) + ) { + sendWsText(socket.handler, [ + "OK", + event.id, + false, + "invalid: mesh connect request missing #p target", + ]); + return; + } sendWsText(socket.handler, ["OK", event.id, true, ""]); return; }