feat: create owner-bound NWC connections

This commit is contained in:
2026-08-22 16:55:24 +02:00
parent 9f6bcad706
commit 228a92b0f0
2 changed files with 163 additions and 27 deletions
+144
View File
@@ -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',
)
})
})
+19 -27
View File
@@ -24,6 +24,7 @@ import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {getPublicKey} from 'nostr-tools/pure' import {getPublicKey} from 'nostr-tools/pure'
import {linkingPubKeyHex} from '../keys'
import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections' import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections'
import {persistNwcConnection} 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 // result is a secp256k1 secret key used ONLY as this connection's
// wallet-service identity - it signs and decrypts NIP-47 events for this // wallet-service identity - it signs and decrypts NIP-47 events for this
// one client, nothing else. // one client, nothing else.
export const deriveNwcWalletKey = ( export const deriveNwcWalletKey = (linkingPrivKey: Uint8Array, clientPubkey: string): Uint8Array =>
linkingPrivKey: Uint8Array,
clientPubkey: string
): Uint8Array =>
sha256( sha256(
new Uint8Array([ new Uint8Array([
...linkingPrivKey, ...linkingPrivKey,
...utf8ToBytes(NWC_WALLET_KEY_CONTEXT), ...utf8ToBytes(NWC_WALLET_KEY_CONTEXT),
...hexToBytes(clientPubkey) ...hexToBytes(clientPubkey),
]) ]),
) )
// the x-only nostr pubkey the client addresses its requests to // the x-only nostr pubkey the client addresses its requests to
@@ -60,12 +58,10 @@ export type NwcConnectionInfo = {
// wallet-service identity // wallet-service identity
export const connectionInfoOf = ( export const connectionInfoOf = (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
record: NwcConnectionRecord record: NwcConnectionRecord,
): NwcConnectionInfo => ({ ): NwcConnectionInfo => ({
record, record,
walletServicePubkey: nwcWalletPubkey( walletServicePubkey: nwcWalletPubkey(deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)),
deriveNwcWalletKey(linkingPrivKey, record.clientPubkey)
)
}) })
export type CreatedConnection = NwcConnectionInfo & { export type CreatedConnection = NwcConnectionInfo & {
@@ -86,20 +82,22 @@ export type CreateConnectionOptions = {
// default; the wallet-service key falls out of the derivation above. // default; the wallet-service key falls out of the derivation above.
export const createConnection = ( export const createConnection = (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
options: CreateConnectionOptions options: CreateConnectionOptions,
): CreatedConnection => { ): CreatedConnection => {
if (options.relays.length === 0) { if (options.relays.length === 0) {
throw new Error('A connection needs at least one relay.') throw new Error('A connection needs at least one relay.')
} }
const clientSecret = const clientSecret = options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32))
const clientPubkey = getPublicKey(clientSecret) const clientPubkey = getPublicKey(clientSecret)
const record = persistNwcConnection({ const ownerId = linkingPubKeyHex(linkingPrivKey)
const record = persistNwcConnection(ownerId, {
version: 1,
ownerId,
clientPubkey, clientPubkey,
relays: options.relays, relays: options.relays,
budget: options.budget, budget: options.budget,
spent: {periodStart: options.now ?? Date.now(), msat: 0}, spent: {periodStart: options.now ?? Date.now(), msat: 0},
createdAt: options.now ?? Date.now() createdAt: options.now ?? Date.now(),
}) })
const info = connectionInfoOf(linkingPrivKey, record) const info = connectionInfoOf(linkingPrivKey, record)
return { return {
@@ -107,19 +105,17 @@ export const createConnection = (
connectionString: buildConnectionString( connectionString: buildConnectionString(
info.walletServicePubkey, info.walletServicePubkey,
bytesToHex(clientSecret), bytesToHex(clientSecret),
record.relays record.relays,
) ),
} }
} }
export const buildConnectionString = ( export const buildConnectionString = (
walletServicePubkey: string, walletServicePubkey: string,
clientSecretHex: string, clientSecretHex: string,
relays: string[] relays: string[],
): string => { ): string => {
const query = relays const query = relays.map((relay) => `relay=${encodeURIComponent(relay)}`).join('&')
.map(relay => `relay=${encodeURIComponent(relay)}`)
.join('&')
return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}` 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 // parses a NIP-47 connection string; returns null for anything that isn't
// exactly one (a client-side counterpart of buildConnectionString, here so // exactly one (a client-side counterpart of buildConnectionString, here so
// the format has a tested inverse) // the format has a tested inverse)
export const parseConnectionString = ( export const parseConnectionString = (uri: string): ParsedConnectionString | null => {
uri: string
): ParsedConnectionString | null => {
let url: URL let url: URL
try { try {
url = new URL(uri.trim()) url = new URL(uri.trim())
@@ -149,9 +143,7 @@ export const parseConnectionString = (
if (!HEX_64.test(walletServicePubkey)) return null if (!HEX_64.test(walletServicePubkey)) return null
const secret = url.searchParams.get('secret') const secret = url.searchParams.get('secret')
if (!secret || !HEX_64.test(secret)) return null if (!secret || !HEX_64.test(secret)) return null
const relays = url.searchParams const relays = url.searchParams.getAll('relay').filter((relay) => /^wss?:\/\//.test(relay))
.getAll('relay')
.filter(relay => /^wss?:\/\//.test(relay))
if (relays.length === 0) return null if (relays.length === 0) return null
return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays} return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays}
} }