diff --git a/src/nwc/NWCClient.test.ts b/src/nwc/NWCClient.test.ts index e02c364..bebaf6a 100644 --- a/src/nwc/NWCClient.test.ts +++ b/src/nwc/NWCClient.test.ts @@ -1,68 +1,246 @@ import "websocket-polyfill"; +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 TEST_RELAY_PRIMARY = "wss://relay.example.invalid/v1"; +const TEST_RELAY_SECONDARY = "wss://relay2.example.invalid/v1"; + +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("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("rejects nsec in connection string (NIP-47 requires hex secret)", () => { + const sk = generateSecretKey(); + const nsec = nip19.nsecEncode(sk); + const url = nwcTestUri({ secret: nsec }); + 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", () => { + expect(() => + 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(nwcTestUri({ relays: "empty" })), + ).toThrow("No relay URL found in connection string"); + }); + + test("rejects invalid relay URL", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + nwcTestUri({ relays: ["not-a-valid-url"] }), + ), + ).toThrow("Invalid relay URL in connection string"); + }); + + test("rejects relay URL with unsupported protocol", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + nwcTestUri({ relays: ["ftp://relay.example.com"] }), + ), + ).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" })), + ).toThrow("Invalid wallet pubkey in connection string"); + }); + + test("rejects wrong-length hex wallet pubkey", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + nwcTestUri({ host: walletPubkeyHex.slice(0, 62) }), + ), + ).toThrow("Invalid wallet pubkey in connection string"); + }); + + test("rejects missing secret by default", () => { + expect(() => + NWCClient.parseWalletConnectUrl( + nwcTestUri({ secret: "omit", lud16: "omit" }), + ), + ).toThrow("No secret found in connection string"); + }); + + test("allows missing secret when requireSecret is false", () => { + const parsed = NWCClient.parseWalletConnectUrl( + nwcTestUri({ secret: "omit", lud16: "omit" }), + { requireSecret: false }, + ); + expect(parsed.secret).toBeUndefined(); + }); + + test("rejects invalid secret", () => { + expect(() => + NWCClient.parseWalletConnectUrl(nwcTestUri({ secret: "invalid" })), + ).toThrow("Invalid secret in connection string"); + }); + + test("constructor merges secret when requireSecret is false", () => { + const explicitSecret = bytesToHex(generateSecretKey()); + const client = new NWCClient({ + nostrWalletConnectUrl: nwcTestUri({ secret: "omit", lud16: "omit" }), + parseWalletConnectUrlOptions: { requireSecret: false }, + secret: explicitSecret, + }); + 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", + ); + }); }); 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 cc171ff..e92a9d7 100644 --- a/src/nwc/NWCClient.ts +++ b/src/nwc/NWCClient.ts @@ -57,6 +57,58 @@ 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:") { + 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)) { + throw new Error("Invalid wallet pubkey in connection string"); + } + return h.toLowerCase(); +} + +function parseNwcSecretParam(secret: string): string { + const s = secret.trim(); + if (!s || !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 per NIP-47). + * + * 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; +}; + export interface NWCOptions { relayUrls: string[]; walletPubkey: string; @@ -70,6 +122,12 @@ export type NewNWCClientOptions = { walletPubkey?: string; nostrWalletConnectUrl?: string; lud16?: string; + /** + * 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; }; export class NWCClient { @@ -81,7 +139,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 +156,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,10 +181,23 @@ 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), - ...options, + ...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 || {}), @@ -125,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; @@ -151,6 +234,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}`;