diff --git a/src/lnurlcash/nwc.connection.cases.ts b/src/lnurlcash/nwc.connection.cases.ts new file mode 100644 index 0000000..7eee998 --- /dev/null +++ b/src/lnurlcash/nwc.connection.cases.ts @@ -0,0 +1,144 @@ +// 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('connection strings', () => { + it('round-trips build -> parse, including several relays', () => { + const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [ + 'wss://relay-a.example', + 'wss://relay-b.example/path?q=1', + ]) + expect(uri).toBe( + `nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`, + ) + expect(parseConnectionString(uri)).toEqual({ + walletServicePubkey: 'ab'.repeat(32), + clientSecret: 'cd'.repeat(32), + relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'], + }) + }) + + it('rejects strings that are not connection strings', () => { + expect(parseConnectionString('not a uri')).toBeNull() + expect(parseConnectionString('https://example.com')).toBeNull() + // missing secret + expect( + parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`), + ).toBeNull() + // missing relay + expect( + parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`), + ).toBeNull() + // a non-hex pubkey + expect( + parseConnectionString( + 'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32), + ), + ).toBeNull() + }) + + it('createConnection returns a string that parses back to the same connection', () => { + const connection = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 100_000, periodMs: 86_400_000}, + clientSecret: CLIENT_SECRET, + }) + const parsed = parseConnectionString(connection.connectionString) + expect(parsed).toEqual({ + walletServicePubkey: connection.walletServicePubkey, + clientSecret: '11'.repeat(32), + relays: RELAYS, + }) + // the record persisted WITHOUT the client secret - it is handed out + // once, in the connection string, and never stored + const records = readNwcConnections(OWNER_ID) + expect(records).toHaveLength(1) + expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY) + expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32)) + }) +}) + +describe('deriveNwcWalletKey', () => { + it('is pinned: derivation changes would silently orphan every connection', () => { + expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( + '71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d', + ) + expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( + 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', + ) + }) + + it('derives a distinct key per client and per linking key', () => { + const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY)) + expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe( + base, + ) + expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base) + }) + + it('re-derives the same wallet identity from a persisted record after a reinstall', () => { + const first = createConnection(LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: 100_000, periodMs: 86_400_000}, + clientSecret: CLIENT_SECRET, + }) + expect(first.walletServicePubkey).toBe( + 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', + ) + }) +}) diff --git a/src/lnurlcash/nwc/connection.ts b/src/lnurlcash/nwc/connection.ts index dec0c16..8407637 100644 --- a/src/lnurlcash/nwc/connection.ts +++ b/src/lnurlcash/nwc/connection.ts @@ -24,6 +24,7 @@ import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {getPublicKey} from 'nostr-tools/pure' +import {linkingPubKeyHex} from '../keys' import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections' import {persistNwcConnection} from '../storage/nwcConnections' @@ -35,16 +36,13 @@ const HEX_64 = /^[0-9a-f]{64}$/i // result is a secp256k1 secret key used ONLY as this connection's // wallet-service identity - it signs and decrypts NIP-47 events for this // one client, nothing else. -export const deriveNwcWalletKey = ( - linkingPrivKey: Uint8Array, - clientPubkey: string -): Uint8Array => +export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array => sha256( new Uint8Array([ ...linkingPrivKey, ...utf8ToBytes(NWC_WALLET_KEY_CONTEXT), - ...hexToBytes(clientPubkey) - ]) + ...hexToBytes(clientPubkey), + ]), ) // the x-only nostr pubkey the client addresses its requests to @@ -60,12 +58,10 @@ export type NwcConnectionInfo = { // wallet-service identity export const connectionInfoOf = ( linkingPrivKey: Uint8Array, - record: NwcConnectionRecord + record: NwcConnectionRecord, ): NwcConnectionInfo => ({ record, - walletServicePubkey: nwcWalletPubkey( - deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) - ) + walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)), }) export type CreatedConnection = NwcConnectionInfo & { @@ -86,20 +82,22 @@ export type CreateConnectionOptions = { // default; the wallet-service key falls out of the derivation above. export const createConnection = ( linkingPrivKey: Uint8Array, - options: CreateConnectionOptions + options: CreateConnectionOptions, ): CreatedConnection => { if (options.relays.length === 0) { throw new Error('A connection needs at least one relay.') } - const clientSecret = - options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32)) + const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32)) const clientPubkey = getPublicKey(clientSecret) - const record = persistNwcConnection({ + const ownerId = linkingPubKeyHex(linkingPrivKey) + const record = persistNwcConnection(ownerId, { + version: 1, + ownerId, clientPubkey, relays: options.relays, budget: options.budget, spent: {periodStart: options.now ?? Date.now(), msat: 0}, - createdAt: options.now ?? Date.now() + createdAt: options.now ?? Date.now(), }) const info = connectionInfoOf(linkingPrivKey, record) return { @@ -107,19 +105,17 @@ export const createConnection = ( connectionString: buildConnectionString( info.walletServicePubkey, bytesToHex(clientSecret), - record.relays - ) + record.relays, + ), } } export const buildConnectionString = ( walletServicePubkey: string, clientSecretHex: string, - relays: string[] + relays: string[], ): string => { - const query = relays - .map(relay => `relay=${encodeURIComponent(relay)}`) - .join('&') + const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&') return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}` } @@ -132,9 +128,7 @@ export type ParsedConnectionString = { // parses a NIP-47 connection string; returns null for anything that isn't // exactly one (a client-side counterpart of buildConnectionString, here so // the format has a tested inverse) -export const parseConnectionString = ( - uri: string -): ParsedConnectionString | null => { +export const parseConnectionString = (uri: string): ParsedConnectionString | null => { let url: URL try { url = new URL(uri.trim()) @@ -149,9 +143,7 @@ export const parseConnectionString = ( if (!HEX_64.test(walletServicePubkey)) return null const secret = url.searchParams.get('secret') if (!secret || !HEX_64.test(secret)) return null - const relays = url.searchParams - .getAll('relay') - .filter(relay => /^wss?:\/\//.test(relay)) + const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay)) if (relays.length === 0) return null return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays} }