mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: create owner-bound NWC connections
This commit is contained in:
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user