From affde56ee63027a8fcaa10d479c52ce9806558e4 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Wed, 15 Apr 2026 17:32:36 +0300 Subject: [PATCH 1/3] feat(nwc): validate wallet connect URI pubkey, relays, and secret --- src/nwc/NWCClient.test.ts | 114 +++++++++++++++++++++++++++++++++++ src/nwc/NWCClient.ts | 123 ++++++++++++++++++++++++++++++++++---- 2 files changed, 226 insertions(+), 11 deletions(-) diff --git a/src/nwc/NWCClient.test.ts b/src/nwc/NWCClient.test.ts index e02c3640..3e881c0b 100644 --- a/src/nwc/NWCClient.test.ts +++ b/src/nwc/NWCClient.test.ts @@ -1,10 +1,15 @@ import "websocket-polyfill"; +import { generateSecretKey, nip19 } from "nostr-tools"; +import { bytesToHex } from "@noble/hashes/utils"; import { NWCClient } from "./NWCClient"; // this has no funds on it, I think ;-) const exampleNwcUrl = "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1&relay=wss://relay2.getalby.com/v1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello@getalby.com"; +const exampleWalletNpub = + "npub1d8hlu76f5mw4eaf9h5ystyt62qzlleyqkk8whr5xzsvv7wh8vrvsnzt7xh"; + describe("parseWalletConnectUrl", () => { test("standard protocol", () => { const parsed = NWCClient.parseWalletConnectUrl(exampleNwcUrl); @@ -50,6 +55,115 @@ describe("parseWalletConnectUrl", () => { "wss://relay2.getalby.com/v1", ]); }); + + test("npub in host decodes to hex pubkey", () => { + const url = exampleNwcUrl.replace( + "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", + exampleWalletNpub, + ); + const parsed = NWCClient.parseWalletConnectUrl(url); + expect(parsed.walletPubkey).toBe( + "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", + ); + }); + + test("nsec secret is normalized to hex", () => { + const sk = generateSecretKey(); + const nsec = nip19.nsecEncode(sk); + const hexSecret = bytesToHex(sk); + const url = exampleNwcUrl.replace( + "secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + `secret=${encodeURIComponent(nsec)}`, + ); + const parsed = NWCClient.parseWalletConnectUrl(url); + expect(parsed.secret).toBe(hexSecret); + const client = new NWCClient({ nostrWalletConnectUrl: url }); + expect(client.secret).toBe(hexSecret); + }); + + test("rejects connection string with no relay", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + ), + ).toThrow("No relay URL found in connection string"); + }); + + test("rejects connection string with only empty relay params", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=&relay=%20&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + ), + ).toThrow("No relay URL found in connection string"); + }); + + test("rejects invalid relay URL", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=not-a-valid-url&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + ), + ).toThrow("Invalid relay URL in connection string"); + }); + + test("rejects relay URL with unsupported protocol", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=ftp://relay.example.com&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + ), + ).toThrow("Invalid relay URL in connection string"); + }); + + test("rejects invalid wallet pubkey", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://not64hex?relay=wss://relay.getalby.com/v1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + ), + ).toThrow("Invalid wallet pubkey in connection string"); + }); + + test("rejects wrong-length hex wallet pubkey", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760?relay=wss://relay.getalby.com/v1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + ), + ).toThrow("Invalid wallet pubkey in connection string"); + }); + + test("rejects missing secret by default", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1", + ), + ).toThrow("No secret found in connection string"); + }); + + test("allows missing secret when requireSecret is false", () => { + const parsed = NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1", + { requireSecret: false }, + ); + expect(parsed.secret).toBeUndefined(); + }); + + test("rejects invalid secret", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1&secret=not_hex", + ), + ).toThrow("Invalid secret in connection string"); + }); + + test("passes parseWalletConnectUrlOptions from constructor", () => { + const client = new NWCClient({ + nostrWalletConnectUrl: + "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1", + parseWalletConnectUrlOptions: { requireSecret: false }, + }); + expect(client.secret).toBeUndefined(); + expect(client.walletPubkey).toBe( + "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", + ); + }); }); describe("NWCClient", () => { diff --git a/src/nwc/NWCClient.ts b/src/nwc/NWCClient.ts index cc171ffa..5247c171 100644 --- a/src/nwc/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -57,6 +57,90 @@ import { } from "./types"; import { SubCloser } from "nostr-tools/lib/types/abstract-pool"; +const NWC_HEX64 = /^[0-9a-fA-F]{64}$/; + +function parseNwcRelayUrls(relayParams: string[]): string[] { + const trimmed = relayParams + .map((r) => r.trim()) + .filter((r) => r.length > 0); + if (!trimmed.length) { + throw new Error("No relay URL found in connection string"); + } + for (const relay of trimmed) { + let parsed: URL; + try { + parsed = new URL(relay); + } catch { + throw new Error(`Invalid relay URL in connection string: ${relay}`); + } + if ( + parsed.protocol !== "wss:" && + parsed.protocol !== "ws:" && + parsed.protocol !== "https:" && + parsed.protocol !== "http:" + ) { + throw new Error(`Invalid relay URL in connection string: ${relay}`); + } + } + return trimmed; +} + +function parseNwcWalletPubkeyFromHost(host: string): string { + const h = host.trim(); + if (NWC_HEX64.test(h)) { + return h.toLowerCase(); + } + const lower = h.toLowerCase(); + if (lower.startsWith("npub")) { + let decoded: ReturnType; + try { + decoded = nip19.decode(h); + } catch { + throw new Error("Invalid wallet pubkey in connection string"); + } + if (decoded.type !== "npub") { + throw new Error("Invalid wallet pubkey in connection string"); + } + return decoded.data as string; + } + throw new Error("Invalid wallet pubkey in connection string"); +} + +function parseNwcSecretParam(secret: string): string { + const s = secret.trim(); + if (!s) { + throw new Error("Invalid secret in connection string"); + } + if (s.toLowerCase().startsWith("nsec")) { + let decoded: ReturnType; + try { + decoded = nip19.decode(s); + } catch { + throw new Error("Invalid secret in connection string"); + } + if (decoded.type !== "nsec") { + throw new Error("Invalid secret in connection string"); + } + if (!(decoded.data instanceof Uint8Array)) { + throw new Error("Invalid secret in connection string"); + } + return bytesToHex(decoded.data); + } + if (!NWC_HEX64.test(s)) { + throw new Error("Invalid secret in connection string"); + } + return s.toLowerCase(); +} + +/** Options for {@link NWCClient.parseWalletConnectUrl}. */ +export type ParseWalletConnectUrlOptions = { + /** + * When true (default), the connection string must include a valid `secret` + * (64-char hex or `nsec` bech32). + */ + requireSecret?: boolean; +}; + export interface NWCOptions { relayUrls: string[]; walletPubkey: string; @@ -70,6 +154,8 @@ export type NewNWCClientOptions = { walletPubkey?: string; nostrWalletConnectUrl?: string; lud16?: string; + /** Used only when {@link nostrWalletConnectUrl} is set. */ + parseWalletConnectUrlOptions?: ParseWalletConnectUrlOptions; }; export class NWCClient { @@ -81,7 +167,15 @@ export class NWCClient { options: NWCOptions; private _encryptionType: Nip47EncryptionType | undefined; - static parseWalletConnectUrl(walletConnectUrl: string): NWCOptions { + /** + * Parse a `nostr+walletconnect` (or legacy `nostrwalletconnect`) URI into {@link NWCOptions}. + * Validates wallet pubkey, relay URLs, and by default the `secret` parameter. + */ + static parseWalletConnectUrl( + walletConnectUrl: string, + parseOptions: ParseWalletConnectUrlOptions = {}, + ): NWCOptions { + const requireSecret = parseOptions.requireSecret !== false; // makes it possible to parse with URL in the different environments (browser/node/...) // parses both new and legacy protocols, with or without "//" walletConnectUrl = walletConnectUrl @@ -90,18 +184,21 @@ export class NWCClient { .replace("nostrwalletconnect:", "http://") .replace("nostr+walletconnect:", "http://"); const url = new URL(walletConnectUrl); - const relayParams = url.searchParams.getAll("relay"); - if (!relayParams) { - throw new Error("No relay URL found in connection string"); - } + const relayUrls = parseNwcRelayUrls(url.searchParams.getAll("relay")); + const walletPubkey = parseNwcWalletPubkeyFromHost(url.host); const options: NWCOptions = { - walletPubkey: url.host, - relayUrls: relayParams, + walletPubkey, + relayUrls, }; const secret = url.searchParams.get("secret"); - if (secret) { - options.secret = secret; + if (requireSecret) { + if (secret === null || secret.trim() === "") { + throw new Error("No secret found in connection string"); + } + options.secret = parseNwcSecretParam(secret); + } else if (secret !== null && secret.trim() !== "") { + options.secret = parseNwcSecretParam(secret); } const lud16 = url.searchParams.get("lud16"); if (lud16) { @@ -112,9 +209,13 @@ export class NWCClient { constructor(options?: NewNWCClientOptions) { if (options && options.nostrWalletConnectUrl) { + const { parseWalletConnectUrlOptions, ...rest } = options; options = { - ...NWCClient.parseWalletConnectUrl(options.nostrWalletConnectUrl), - ...options, + ...NWCClient.parseWalletConnectUrl( + options.nostrWalletConnectUrl, + parseWalletConnectUrlOptions, + ), + ...rest, }; } this.options = { From dc2b86adeec46538aea9fe093c2766f018fffe42 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Wed, 15 Apr 2026 17:43:40 +0300 Subject: [PATCH 2/3] feat(nwc): validate connect URI and harden secretless client handling --- src/nwc/NWCClient.test.ts | 189 +++++++++++++++++++++++--------------- src/nwc/NWCClient.ts | 32 ++++++- 2 files changed, 142 insertions(+), 79 deletions(-) diff --git a/src/nwc/NWCClient.test.ts b/src/nwc/NWCClient.test.ts index 3e881c0b..56ecfe08 100644 --- a/src/nwc/NWCClient.test.ts +++ b/src/nwc/NWCClient.test.ts @@ -1,80 +1,113 @@ import "websocket-polyfill"; -import { generateSecretKey, nip19 } from "nostr-tools"; +import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools"; import { bytesToHex } from "@noble/hashes/utils"; import { NWCClient } from "./NWCClient"; -// this has no funds on it, I think ;-) -const exampleNwcUrl = - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1&relay=wss://relay2.getalby.com/v1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello@getalby.com"; +/** Synthetic keys for unit tests only (not real wallet credentials). */ +const walletSecretKey = generateSecretKey(); +const walletPubkeyHex = getPublicKey(walletSecretKey); +const nwcSharedSecretHex = bytesToHex(generateSecretKey()); -const exampleWalletNpub = - "npub1d8hlu76f5mw4eaf9h5ystyt62qzlleyqkk8whr5xzsvv7wh8vrvsnzt7xh"; +const TEST_RELAY_PRIMARY = "wss://relay.example.invalid/v1"; +const TEST_RELAY_SECONDARY = "wss://relay2.example.invalid/v1"; + +const exampleWalletNpub = nip19.npubEncode(walletPubkeyHex); + +function nwcTestUri(config: { + scheme?: "nostr+walletconnect://" | "nostr+walletconnect:" | "nostrwalletconnect:"; + host?: string; + relays?: "default" | "none" | "empty" | string[]; + secret?: "default" | "omit" | "invalid" | string; + lud16?: string | "omit"; +}): string { + const scheme = config.scheme ?? "nostr+walletconnect://"; + const host = config.host ?? walletPubkeyHex; + const params: string[] = []; + + const relayMode = config.relays ?? "default"; + if (relayMode === "none") { + // intentionally omit relay params + } else if (relayMode === "empty") { + params.push("relay=", "relay=%20"); + } else if (relayMode === "default") { + params.push( + `relay=${encodeURIComponent(TEST_RELAY_PRIMARY)}`, + `relay=${encodeURIComponent(TEST_RELAY_SECONDARY)}`, + ); + } else { + for (const r of relayMode) { + params.push(`relay=${encodeURIComponent(r)}`); + } + } + + const secretMode = config.secret ?? "default"; + if (secretMode === "omit") { + // omit secret param + } else if (secretMode === "invalid") { + params.push("secret=not_hex"); + } else if (secretMode === "default") { + params.push(`secret=${nwcSharedSecretHex}`); + } else { + params.push(`secret=${encodeURIComponent(secretMode)}`); + } + + if (config.lud16 !== "omit") { + params.push( + `lud16=${encodeURIComponent(config.lud16 ?? "payee@example.invalid")}`, + ); + } + + const qs = params.length > 0 ? `?${params.join("&")}` : ""; + return `${scheme}${host}${qs}`; +} + +const exampleNwcUrl = nwcTestUri({}); describe("parseWalletConnectUrl", () => { test("standard protocol", () => { const parsed = NWCClient.parseWalletConnectUrl(exampleNwcUrl); - expect(parsed.walletPubkey).toBe( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - ); - expect(parsed.secret).toBe( - "e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", - ); + expect(parsed.walletPubkey).toBe(walletPubkeyHex); + expect(parsed.secret).toBe(nwcSharedSecretHex); expect(parsed.relayUrls).toEqual([ - "wss://relay.getalby.com/v1", - "wss://relay2.getalby.com/v1", + TEST_RELAY_PRIMARY, + TEST_RELAY_SECONDARY, ]); - expect(parsed.lud16).toBe("hello@getalby.com"); + expect(parsed.lud16).toBe("payee@example.invalid"); }); test("protocol without double slash", () => { const parsed = NWCClient.parseWalletConnectUrl( - exampleNwcUrl.replace("nostr+walletconnect://", "nostr+walletconnect:"), - ); - expect(parsed.walletPubkey).toBe( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - ); - expect(parsed.secret).toBe( - "e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + nwcTestUri({ scheme: "nostr+walletconnect:" }), ); + expect(parsed.walletPubkey).toBe(walletPubkeyHex); + expect(parsed.secret).toBe(nwcSharedSecretHex); expect(parsed.relayUrls).toEqual([ - "wss://relay.getalby.com/v1", - "wss://relay2.getalby.com/v1", + TEST_RELAY_PRIMARY, + TEST_RELAY_SECONDARY, ]); }); test("legacy protocol without double slash", () => { const parsed = NWCClient.parseWalletConnectUrl( - exampleNwcUrl.replace("nostr+walletconnect://", "nostrwalletconnect:"), - ); - expect(parsed.walletPubkey).toBe( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - ); - expect(parsed.secret).toBe( - "e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + nwcTestUri({ scheme: "nostrwalletconnect:" }), ); + expect(parsed.walletPubkey).toBe(walletPubkeyHex); + expect(parsed.secret).toBe(nwcSharedSecretHex); expect(parsed.relayUrls).toEqual([ - "wss://relay.getalby.com/v1", - "wss://relay2.getalby.com/v1", + TEST_RELAY_PRIMARY, + TEST_RELAY_SECONDARY, ]); }); test("npub in host decodes to hex pubkey", () => { - const url = exampleNwcUrl.replace( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - exampleWalletNpub, - ); + const url = nwcTestUri({ host: exampleWalletNpub }); const parsed = NWCClient.parseWalletConnectUrl(url); - expect(parsed.walletPubkey).toBe( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - ); + expect(parsed.walletPubkey).toBe(walletPubkeyHex); }); test("nsec secret is normalized to hex", () => { const sk = generateSecretKey(); const nsec = nip19.nsecEncode(sk); const hexSecret = bytesToHex(sk); - const url = exampleNwcUrl.replace( - "secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", - `secret=${encodeURIComponent(nsec)}`, - ); + const url = nwcTestUri({ secret: nsec }); const parsed = NWCClient.parseWalletConnectUrl(url); expect(parsed.secret).toBe(hexSecret); const client = new NWCClient({ nostrWalletConnectUrl: url }); @@ -83,24 +116,20 @@ describe("parseWalletConnectUrl", () => { test("rejects connection string with no relay", () => { expect(() => - NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", - ), + NWCClient.parseWalletConnectUrl(nwcTestUri({ relays: "none" })), ).toThrow("No relay URL found in connection string"); }); test("rejects connection string with only empty relay params", () => { expect(() => - NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=&relay=%20&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", - ), + NWCClient.parseWalletConnectUrl(nwcTestUri({ relays: "empty" })), ).toThrow("No relay URL found in connection string"); }); test("rejects invalid relay URL", () => { expect(() => NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=not-a-valid-url&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + nwcTestUri({ relays: ["not-a-valid-url"] }), ), ).toThrow("Invalid relay URL in connection string"); }); @@ -108,23 +137,21 @@ describe("parseWalletConnectUrl", () => { test("rejects relay URL with unsupported protocol", () => { expect(() => NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=ftp://relay.example.com&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + nwcTestUri({ relays: ["ftp://relay.example.com"] }), ), ).toThrow("Invalid relay URL in connection string"); }); test("rejects invalid wallet pubkey", () => { expect(() => - NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://not64hex?relay=wss://relay.getalby.com/v1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", - ), + NWCClient.parseWalletConnectUrl(nwcTestUri({ host: "not64hex" })), ).toThrow("Invalid wallet pubkey in connection string"); }); test("rejects wrong-length hex wallet pubkey", () => { expect(() => NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760?relay=wss://relay.getalby.com/v1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + nwcTestUri({ host: walletPubkeyHex.slice(0, 62) }), ), ).toThrow("Invalid wallet pubkey in connection string"); }); @@ -132,14 +159,14 @@ describe("parseWalletConnectUrl", () => { test("rejects missing secret by default", () => { expect(() => NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1", + nwcTestUri({ secret: "omit", lud16: "omit" }), ), ).toThrow("No secret found in connection string"); }); test("allows missing secret when requireSecret is false", () => { const parsed = NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1", + nwcTestUri({ secret: "omit", lud16: "omit" }), { requireSecret: false }, ); expect(parsed.secret).toBeUndefined(); @@ -147,21 +174,29 @@ describe("parseWalletConnectUrl", () => { test("rejects invalid secret", () => { expect(() => - NWCClient.parseWalletConnectUrl( - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1&secret=not_hex", - ), + NWCClient.parseWalletConnectUrl(nwcTestUri({ secret: "invalid" })), ).toThrow("Invalid secret in connection string"); }); - test("passes parseWalletConnectUrlOptions from constructor", () => { + test("constructor merges secret when requireSecret is false", () => { + const explicitSecret = bytesToHex(generateSecretKey()); const client = new NWCClient({ - nostrWalletConnectUrl: - "nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss://relay.getalby.com/v1", + nostrWalletConnectUrl: nwcTestUri({ secret: "omit", lud16: "omit" }), parseWalletConnectUrlOptions: { requireSecret: false }, + secret: explicitSecret, }); - expect(client.secret).toBeUndefined(); - expect(client.walletPubkey).toBe( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", + expect(client.secret).toBe(explicitSecret); + expect(client.walletPubkey).toBe(walletPubkeyHex); + }); + + test("constructor rejects requireSecret false without explicit secret", () => { + expect(() => + new NWCClient({ + nostrWalletConnectUrl: nwcTestUri({ secret: "omit", lud16: "omit" }), + parseWalletConnectUrlOptions: { requireSecret: false }, + }), + ).toThrow( + "NWCClient requires a client secret: pass `secret` when using parseWalletConnectUrlOptions.requireSecret: false without a secret in the URI", ); }); }); @@ -169,14 +204,20 @@ describe("parseWalletConnectUrl", () => { describe("NWCClient", () => { test("standard protocol", () => { const nwcClient = new NWCClient({ nostrWalletConnectUrl: exampleNwcUrl }); - expect(nwcClient.walletPubkey).toBe( - "69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", - ); - expect(nwcClient.secret).toBe( - "e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", + expect(nwcClient.walletPubkey).toBe(walletPubkeyHex); + expect(nwcClient.secret).toBe(nwcSharedSecretHex); + expect(nwcClient.lud16).toBe("payee@example.invalid"); + expect(nwcClient.options.lud16).toBe("payee@example.invalid"); + }); + + test("getNostrWalletConnectUrl throws without client secret", () => { + const client = new NWCClient({ + relayUrls: [TEST_RELAY_PRIMARY], + walletPubkey: walletPubkeyHex, + }); + expect(() => client.getNostrWalletConnectUrl()).toThrow( + "Cannot build Nostr Wallet Connect URL without a client secret", ); - expect(nwcClient.lud16).toBe("hello@getalby.com"); - expect(nwcClient.options.lud16).toBe("hello@getalby.com"); }); }); diff --git a/src/nwc/NWCClient.ts b/src/nwc/NWCClient.ts index 5247c171..86724b2e 100644 --- a/src/nwc/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -137,6 +137,10 @@ export type ParseWalletConnectUrlOptions = { /** * When true (default), the connection string must include a valid `secret` * (64-char hex or `nsec` bech32). + * + * When false, {@link NWCClient} must still receive a client `secret` via + * constructor options if you intend to call wallet APIs or + * {@link NWCClient.getNostrWalletConnectUrl}. */ requireSecret?: boolean; }; @@ -154,7 +158,11 @@ export type NewNWCClientOptions = { walletPubkey?: string; nostrWalletConnectUrl?: string; lud16?: string; - /** Used only when {@link nostrWalletConnectUrl} is set. */ + /** + * Used only when {@link nostrWalletConnectUrl} is set. + * If {@link ParseWalletConnectUrlOptions.requireSecret} is false and the URI + * omits `secret`, you must pass `secret` alongside `nostrWalletConnectUrl`. + */ parseWalletConnectUrlOptions?: ParseWalletConnectUrlOptions; }; @@ -210,13 +218,22 @@ export class NWCClient { constructor(options?: NewNWCClientOptions) { if (options && options.nostrWalletConnectUrl) { const { parseWalletConnectUrlOptions, ...rest } = options; + const parsed = NWCClient.parseWalletConnectUrl( + options.nostrWalletConnectUrl, + parseWalletConnectUrlOptions, + ); options = { - ...NWCClient.parseWalletConnectUrl( - options.nostrWalletConnectUrl, - parseWalletConnectUrlOptions, - ), + ...parsed, ...rest, }; + if ( + parseWalletConnectUrlOptions?.requireSecret === false && + !options.secret + ) { + throw new Error( + "NWCClient requires a client secret: pass `secret` when using parseWalletConnectUrlOptions.requireSecret: false without a secret in the URI", + ); + } } this.options = { ...(options || {}), @@ -252,6 +269,11 @@ export class NWCClient { } getNostrWalletConnectUrl(includeSecret = true) { + if (!this.secret) { + throw new Error( + "Cannot build Nostr Wallet Connect URL without a client secret", + ); + } let url = `nostr+walletconnect://${this.walletPubkey}?relay=${this.relayUrls.join("&relay=")}&pubkey=${this.publicKey}`; if (includeSecret) { url = `${url}&secret=${this.secret}`; From db8d3da14260688f1b11b4d58ec511ece19c7d3c Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Thu, 16 Apr 2026 19:37:22 +0300 Subject: [PATCH 3/3] fix(nwc): align NWC URI validation with NIP-47 --- src/nwc/NWCClient.test.ts | 45 ++++++++++++++++++++++++-------- src/nwc/NWCClient.ts | 55 +++++++-------------------------------- 2 files changed, 44 insertions(+), 56 deletions(-) diff --git a/src/nwc/NWCClient.test.ts b/src/nwc/NWCClient.test.ts index 56ecfe08..bebaf6a5 100644 --- a/src/nwc/NWCClient.test.ts +++ b/src/nwc/NWCClient.test.ts @@ -11,8 +11,6 @@ const nwcSharedSecretHex = bytesToHex(generateSecretKey()); const TEST_RELAY_PRIMARY = "wss://relay.example.invalid/v1"; const TEST_RELAY_SECONDARY = "wss://relay2.example.invalid/v1"; -const exampleWalletNpub = nip19.npubEncode(walletPubkeyHex); - function nwcTestUri(config: { scheme?: "nostr+walletconnect://" | "nostr+walletconnect:" | "nostrwalletconnect:"; host?: string; @@ -97,21 +95,33 @@ describe("parseWalletConnectUrl", () => { ]); }); - test("npub in host decodes to hex pubkey", () => { - const url = nwcTestUri({ host: exampleWalletNpub }); - const parsed = NWCClient.parseWalletConnectUrl(url); - expect(parsed.walletPubkey).toBe(walletPubkeyHex); + test("rejects npub in host (NIP-47 requires hex pubkey)", () => { + const url = nwcTestUri({ host: nip19.npubEncode(walletPubkeyHex) }); + expect(() => NWCClient.parseWalletConnectUrl(url)).toThrow( + "Invalid wallet pubkey in connection string", + ); }); - test("nsec secret is normalized to hex", () => { + test("rejects nsec in connection string (NIP-47 requires hex secret)", () => { const sk = generateSecretKey(); const nsec = nip19.nsecEncode(sk); - const hexSecret = bytesToHex(sk); const url = nwcTestUri({ secret: nsec }); - const parsed = NWCClient.parseWalletConnectUrl(url); - expect(parsed.secret).toBe(hexSecret); - const client = new NWCClient({ nostrWalletConnectUrl: url }); + expect(() => NWCClient.parseWalletConnectUrl(url)).toThrow( + "Invalid secret in connection string", + ); + }); + + test("constructor accepts nsec as explicit secret option (normalized to hex)", () => { + const sk = generateSecretKey(); + const nsec = nip19.nsecEncode(sk); + const hexSecret = bytesToHex(sk); + const client = new NWCClient({ + nostrWalletConnectUrl: nwcTestUri({ secret: "omit", lud16: "omit" }), + parseWalletConnectUrlOptions: { requireSecret: false }, + secret: nsec, + }); expect(client.secret).toBe(hexSecret); + expect(client.publicKey).toBe(getPublicKey(sk)); }); test("rejects connection string with no relay", () => { @@ -142,6 +152,19 @@ describe("parseWalletConnectUrl", () => { ).toThrow("Invalid relay URL in connection string"); }); + test("rejects relay URL with http or https (NIP-47 uses WebSocket relays)", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + nwcTestUri({ relays: ["http://relay.example.invalid/v1"] }), + ), + ).toThrow("Invalid relay URL in connection string"); + expect(() => + NWCClient.parseWalletConnectUrl( + nwcTestUri({ relays: ["https://relay.example.invalid/v1"] }), + ), + ).toThrow("Invalid relay URL in connection string"); + }); + test("rejects invalid wallet pubkey", () => { expect(() => NWCClient.parseWalletConnectUrl(nwcTestUri({ host: "not64hex" })), diff --git a/src/nwc/NWCClient.ts b/src/nwc/NWCClient.ts index 86724b2e..e92a9d74 100644 --- a/src/nwc/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -73,12 +73,7 @@ function parseNwcRelayUrls(relayParams: string[]): string[] { } catch { throw new Error(`Invalid relay URL in connection string: ${relay}`); } - if ( - parsed.protocol !== "wss:" && - parsed.protocol !== "ws:" && - parsed.protocol !== "https:" && - parsed.protocol !== "http:" - ) { + if (parsed.protocol !== "wss:" && parsed.protocol !== "ws:") { throw new Error(`Invalid relay URL in connection string: ${relay}`); } } @@ -87,46 +82,15 @@ function parseNwcRelayUrls(relayParams: string[]): string[] { function parseNwcWalletPubkeyFromHost(host: string): string { const h = host.trim(); - if (NWC_HEX64.test(h)) { - return h.toLowerCase(); + if (!NWC_HEX64.test(h)) { + throw new Error("Invalid wallet pubkey in connection string"); } - const lower = h.toLowerCase(); - if (lower.startsWith("npub")) { - let decoded: ReturnType; - try { - decoded = nip19.decode(h); - } catch { - throw new Error("Invalid wallet pubkey in connection string"); - } - if (decoded.type !== "npub") { - throw new Error("Invalid wallet pubkey in connection string"); - } - return decoded.data as string; - } - throw new Error("Invalid wallet pubkey in connection string"); + return h.toLowerCase(); } function parseNwcSecretParam(secret: string): string { const s = secret.trim(); - if (!s) { - throw new Error("Invalid secret in connection string"); - } - if (s.toLowerCase().startsWith("nsec")) { - let decoded: ReturnType; - try { - decoded = nip19.decode(s); - } catch { - throw new Error("Invalid secret in connection string"); - } - if (decoded.type !== "nsec") { - throw new Error("Invalid secret in connection string"); - } - if (!(decoded.data instanceof Uint8Array)) { - throw new Error("Invalid secret in connection string"); - } - return bytesToHex(decoded.data); - } - if (!NWC_HEX64.test(s)) { + if (!s || !NWC_HEX64.test(s)) { throw new Error("Invalid secret in connection string"); } return s.toLowerCase(); @@ -136,7 +100,7 @@ function parseNwcSecretParam(secret: string): string { export type ParseWalletConnectUrlOptions = { /** * When true (default), the connection string must include a valid `secret` - * (64-char hex or `nsec` bech32). + * (64-char hex per NIP-47). * * When false, {@link NWCClient} must still receive a client `secret` via * constructor options if you intend to call wallet APIs or @@ -243,10 +207,11 @@ export class NWCClient { this.pool = new SimplePool({ }); if (this.options.secret) { + const s = this.options.secret; this.secret = ( - this.options.secret.toLowerCase().startsWith("nsec") - ? nip19.decode(this.options.secret).data - : this.options.secret + s.toLowerCase().startsWith("nsec") + ? bytesToHex(nip19.decode(s).data as Uint8Array) + : s.toLowerCase() ) as string; } this.lud16 = this.options.lud16;