From b52172c0cb28884b0d00636f601cac0a52e6d504 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:35 +0200 Subject: [PATCH] fix: validate NWC requests before dispatch --- src/lnurlcash/nwc.validation.cases.ts | 187 ++++++++++++++++++++++++++ src/lnurlcash/nwc/dispatch.ts | 72 ++++------ src/lnurlcash/nwc/protocol.ts | 58 +++----- 3 files changed, 236 insertions(+), 81 deletions(-) create mode 100644 src/lnurlcash/nwc.validation.cases.ts diff --git a/src/lnurlcash/nwc.validation.cases.ts b/src/lnurlcash/nwc.validation.cases.ts new file mode 100644 index 0000000..09fdc54 --- /dev/null +++ b/src/lnurlcash/nwc.validation.cases.ts @@ -0,0 +1,187 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' +import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: request validation', () => { + it('answers an unknown method with NOT_IMPLEMENTED', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'get_payments', {}) + expect(response.result_type).toBe('get_payments') + expect(response.error?.code).toBe('NOT_IMPLEMENTED') + expect(response.result).toBeNull() + await stop() + }) + + it('answers a malformed (non-JSON) request with an error, not a crash', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const request = clientRequest(walletServicePubkey, 'this is not json') + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) + const response = requiredValue(readResponse(relay.published, request.id, 'nip44_v2')) + expect(response.error?.code).toBe('OTHER') + await stop() + }) + + it('answers a JSON request without a method with an error', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}})) + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) + expect(requiredValue(readResponse(relay.published, request.id, 'nip44_v2')).error?.code).toBe( + 'OTHER', + ) + await stop() + }) + + it('ignores a request signed by a stranger key - silently', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const content = nip44v2.encrypt( + JSON.stringify({method: 'get_balance', params: {}}), + nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey), + ) + const forged = finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: nowSeconds(), + tags: [ + ['p', walletServicePubkey], + ['encryption', 'nip44_v2'], + ], + content, + }, + STRANGER_SECRET, + ) + relay.emit(forged) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) + await stop() + }) + + it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const content = nip04Encrypt( + CLIENT_SECRET, + walletServicePubkey, + JSON.stringify({method: 'get_balance', params: {}}), + ) + const request = finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: nowSeconds(), + tags: [ + ['p', walletServicePubkey], + ['encryption', 'nip17'], + ], + content, + }, + CLIENT_SECRET, + ) + relay.emit(request) + // the error answer goes out in the legacy scheme every client reads + await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) + expect(requiredValue(readResponse(relay.published, request.id, 'nip04')).error?.code).toBe( + 'UNSUPPORTED_ENCRYPTION', + ) + await stop() + }) + + it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + for (const scheme of ['none', 'nip04'] as const) { + const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme) + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, 'nip04') !== null) + const response = requiredValue(readResponse(relay.published, request.id, 'nip04')) + expect(response.error).toBeNull() + expect(response.result).toEqual({balance: 0}) + // the response mirrors the request's scheme + const event = requiredValue( + relay.published.find( + (e) => + e.kind === NWC_RESPONSE_KIND && e.tags.some((t) => t[0] === 'e' && t[1] === request.id), + ), + ) + expect(event.tags).toContainEqual(['encryption', 'nip04']) + } + await stop() + }) + + it('drops requests older than the replay window unanswered', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const stale = methodRequest( + walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + nowSeconds() - 1200, + ) + relay.emit(stale) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) + await stop() + }) + + it('picks up no new requests after stop', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + await stop() + const request = methodRequest(walletServicePubkey, 'get_balance', {}) + relay.emitAfterClose(request) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(relay.published.filter((e) => e.kind === NWC_RESPONSE_KIND)).toHaveLength(0) + }) +}) diff --git a/src/lnurlcash/nwc/dispatch.ts b/src/lnurlcash/nwc/dispatch.ts index 36651ea..99c06f8 100644 --- a/src/lnurlcash/nwc/dispatch.ts +++ b/src/lnurlcash/nwc/dispatch.ts @@ -9,6 +9,7 @@ import {noteK1, sameInvoice} from 'lnurlcash-kit' import type {Bearer} from '../types' import type {PreparedMint} from '../ops' import {prepareMint} from '../ops' +import {PollAbortedError} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices' @@ -19,10 +20,7 @@ import {NWC_METHODS, errResult, okResult} from './protocol' // the same eligibility carve applies - the balance answers "what could // this wallet actually pay with right now" const spendable = (bearer: Bearer): boolean => - !bearer.spent && - bearer.callback !== '' && - !bearer.deviceId && - !!noteK1(bearer.url) + !bearer.spent && bearer.callback !== '' && !bearer.deviceId && !!noteK1(bearer.url) const handleGetInfo = (ctx: RequestContext): NwcResponse => okResult('get_info', { @@ -34,7 +32,7 @@ const handleGetInfo = (ctx: RequestContext): NwcResponse => // block height/hash exists, so those fields are simply absent. network: 'mainnet', methods: [...NWC_METHODS], - notifications: [] + notifications: [], }) const handleGetBalance = (ctx: RequestContext): NwcResponse => @@ -42,20 +40,16 @@ const handleGetBalance = (ctx: RequestContext): NwcResponse => balance: ctx.deps .getBearers() .filter(spendable) - .reduce((sum, b) => sum + b.amount, 0) + .reduce((sum, b) => sum + b.amount, 0), }) const handleMakeInvoice = async ( ctx: RequestContext, - params: Record + params: Record, ): Promise => { const amountMsat = Number(params.amount) if (!Number.isInteger(amountMsat) || amountMsat <= 0) { - return errResult( - 'make_invoice', - 'OTHER', - 'Amount must be a positive whole number of msat.' - ) + return errResult('make_invoice', 'OTHER', 'Amount must be a positive whole number of msat.') } const mint = ctx.deps.getDefaultMint() if (!mint) { @@ -65,11 +59,7 @@ const handleMakeInvoice = async ( try { prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {}) } catch (err) { - return errResult( - 'make_invoice', - 'INTERNAL', - err instanceof Error ? err.message : String(err) - ) + return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err)) } const entry: PendingInvoice = { invoice: prepared.invoice, @@ -77,7 +67,7 @@ const handleMakeInvoice = async ( amountMsat: prepared.grossMsat, createdAt: ctx.nowSeconds(), prepared, - state: 'pending' + state: 'pending', } if (typeof params.description === 'string' && params.description) { entry.description = params.description @@ -88,30 +78,29 @@ const handleMakeInvoice = async ( } ctx.invoices.set(entry.paymentHash, entry) // phase two runs in the background; the invoice goes out now and - // lookup_invoice reports the settlement the claim observes - void settleAndClaim(ctx, entry).catch(err => { + // lookup_invoice reports the settlement the service-owned claim observes + const started = ctx.startBackground(() => + settleAndClaim(ctx, entry).catch((err) => { + entry.state = 'failed' + // an interrupted claim poll is normal service teardown (stop + // aborted it), not a background failure worth surfacing + if (err instanceof PollAbortedError) return + ctx.deps.onError?.(err, ctx.connection()) + }), + ) + if (!started) { entry.state = 'failed' - ctx.deps.onError?.(err, ctx.connection()) - }) + return errResult('make_invoice', 'INTERNAL', 'The wallet service is stopping.') + } return okResult('make_invoice', invoiceResult(entry)) } -const handleLookupInvoice = ( - ctx: RequestContext, - params: Record -): NwcResponse => { - const invoiceParam = - typeof params.invoice === 'string' ? params.invoice : undefined +const handleLookupInvoice = (ctx: RequestContext, params: Record): NwcResponse => { + const invoiceParam = typeof params.invoice === 'string' ? params.invoice : undefined const hashParam = - typeof params.payment_hash === 'string' - ? params.payment_hash.toLowerCase() - : undefined + typeof params.payment_hash === 'string' ? params.payment_hash.toLowerCase() : undefined if (!invoiceParam && !hashParam) { - return errResult( - 'lookup_invoice', - 'OTHER', - 'Provide an invoice or a payment hash.' - ) + return errResult('lookup_invoice', 'OTHER', 'Provide an invoice or a payment hash.') } let entry = hashParam ? ctx.invoices.get(hashParam) : undefined if (!entry && invoiceParam) { @@ -128,10 +117,7 @@ const handleLookupInvoice = ( return okResult('lookup_invoice', invoiceResult(entry)) } -export const dispatch = async ( - ctx: RequestContext, - request: NwcRequest -): Promise => { +export const dispatch = async (ctx: RequestContext, request: NwcRequest): Promise => { switch (request.method) { case 'get_info': return handleGetInfo(ctx) @@ -144,10 +130,6 @@ export const dispatch = async ( case 'lookup_invoice': return handleLookupInvoice(ctx, request.params) default: - return errResult( - request.method, - 'NOT_IMPLEMENTED', - `Unknown method: ${request.method}.` - ) + return errResult(request.method, 'NOT_IMPLEMENTED', `Unknown method: ${request.method}.`) } } diff --git a/src/lnurlcash/nwc/protocol.ts b/src/lnurlcash/nwc/protocol.ts index 60e4f7e..a7ccaba 100644 --- a/src/lnurlcash/nwc/protocol.ts +++ b/src/lnurlcash/nwc/protocol.ts @@ -29,7 +29,7 @@ export const NWC_METHODS = [ 'get_balance', 'make_invoice', 'pay_invoice', - 'lookup_invoice' + 'lookup_invoice', ] as const export type NwcMethod = (typeof NWC_METHODS)[number] @@ -61,17 +61,13 @@ export type NwcResponse = { export const okResult = (method: string, result: unknown): NwcResponse => ({ result_type: method, error: null, - result + result, }) -export const errResult = ( - method: string, - code: NwcErrorCode, - message: string -): NwcResponse => ({ +export const errResult = (method: string, code: NwcErrorCode, message: string): NwcResponse => ({ result_type: method, error: {code, message}, - result: null + result: null, }) // the two encryption schemes this service speaks; the scheme of a request @@ -79,16 +75,14 @@ export const errResult = ( // requested by the client") export type NwcEncryption = 'nip44_v2' | 'nip04' -const conversationKey = ( - walletSecretKey: Uint8Array, - clientPubkey: string -): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) +const conversationKey = (walletSecretKey: Uint8Array, clientPubkey: string): Uint8Array => + nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) export const encryptFor = ( scheme: NwcEncryption, walletSecretKey: Uint8Array, clientPubkey: string, - plaintext: string + plaintext: string, ): string => scheme === 'nip44_v2' ? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey)) @@ -98,14 +92,14 @@ const decryptFrom = ( scheme: NwcEncryption, walletSecretKey: Uint8Array, clientPubkey: string, - content: string + content: string, ): string => scheme === 'nip44_v2' ? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey)) : nip04Decrypt(walletSecretKey, clientPubkey, content) const tagValue = (event: NostrEvent, name: string): string | undefined => - event.tags.find(t => t[0] === name)?.[1] + event.tags.find((t) => t[0] === name)?.[1] // The outcome of validating + decrypting a candidate request event: // - a request to dispatch (encryption scheme carried so the response can @@ -126,7 +120,7 @@ export const decryptRequest = ( walletServicePubkey: string, clientPubkey: string, event: NostrEvent, - nowSeconds: number = Math.floor(Date.now() / 1000) + nowSeconds: number = Math.floor(Date.now() / 1000), ): DecryptedNwcRequest | null => { if (event.kind !== NWC_REQUEST_KIND) return null // only the authorized client may talk to this connection, and the @@ -155,8 +149,8 @@ export const decryptRequest = ( response: errResult( '', 'UNSUPPORTED_ENCRYPTION', - `Unsupported encryption scheme: ${advertised}.` - ) + `Unsupported encryption scheme: ${advertised}.`, + ), } } let plaintext: string @@ -173,7 +167,7 @@ export const decryptRequest = ( return { respond: true, encryption, - response: errResult('', 'OTHER', 'The request is not valid JSON.') + response: errResult('', 'OTHER', 'The request is not valid JSON.'), } } if ( @@ -185,14 +179,11 @@ export const decryptRequest = ( return { respond: true, encryption, - response: errResult('', 'OTHER', 'The request has no method.') + response: errResult('', 'OTHER', 'The request has no method.'), } } const request = data as NwcRequest - const params = - typeof request.params === 'object' && request.params !== null - ? request.params - : {} + const params = typeof request.params === 'object' && request.params !== null ? request.params : {} return {respond: false, request: {method: request.method, params}, encryption} } @@ -204,7 +195,7 @@ export const buildResponseEvent = ( encryption: NwcEncryption, requestEventId: string, response: NwcResponse, - createdAt: number = Math.floor(Date.now() / 1000) + createdAt: number = Math.floor(Date.now() / 1000), ): NostrEvent => finalizeEvent( { @@ -213,29 +204,24 @@ export const buildResponseEvent = ( tags: [ ['p', clientPubkey], ['e', requestEventId], - ['encryption', encryption] + ['encryption', encryption], ], - content: encryptFor( - encryption, - walletSecretKey, - clientPubkey, - JSON.stringify(response) - ) + content: encryptFor(encryption, walletSecretKey, clientPubkey, JSON.stringify(response)), }, - walletSecretKey + walletSecretKey, ) // the replaceable info event advertising this service's capabilities export const buildInfoEvent = ( walletSecretKey: Uint8Array, - createdAt: number = Math.floor(Date.now() / 1000) + createdAt: number = Math.floor(Date.now() / 1000), ): NostrEvent => finalizeEvent( { kind: NWC_INFO_KIND, created_at: createdAt, tags: [['encryption', 'nip44_v2 nip04']], - content: NWC_METHODS.join(' ') + content: NWC_METHODS.join(' '), }, - walletSecretKey + walletSecretKey, )