Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion desktop/src/shared/api/relayMeshSignaling.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions desktop/src/shared/lib/pubkey.test.mjs
Original file line number Diff line number Diff line change
@@ -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"));
});
31 changes: 31 additions & 0 deletions desktop/src/shared/lib/pubkey.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { decode } from "nostr-tools/nip19";

/**
* Canonical pubkey normalisation.
*
Expand All @@ -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;
}
19 changes: 16 additions & 3 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down