From 7b31a2644896d4f450b4c9123cca488ddf7a8307 Mon Sep 17 00:00:00 2001 From: beeman Date: Wed, 3 Jun 2026 01:37:37 +0700 Subject: [PATCH] fix: align remote nostr protocol Derive Nostr session routing from the association key and emit spec-shaped pairing URLs. Require CONNECT and SESSION_END control messages, terminate on relay CLOSED/failed OK messages, and cover the protocol behavior with browser and node tests. --- src/browser/create-remote-wallet-session.ts | 126 ++++-- src/node/connect-remote-wallet.ts | 20 +- src/protocol/association-url.ts | 38 +- src/protocol/types.ts | 1 + .../create-remote-wallet-session.test.ts | 418 ++++++++++++++++-- test/node/connect-remote-wallet.test.ts | 253 ++++++++++- test/protocol/association-url.test.ts | 77 +++- test/react/use-remote-wallet-pairing.test.ts | 104 ++++- 8 files changed, 954 insertions(+), 83 deletions(-) diff --git a/src/browser/create-remote-wallet-session.ts b/src/browser/create-remote-wallet-session.ts index a3315f1..dbee802 100644 --- a/src/browser/create-remote-wallet-session.ts +++ b/src/browser/create-remote-wallet-session.ts @@ -15,6 +15,7 @@ import { base64ToBytes, bytesToBase58, bytesToBase64 } from '../protocol/encodin import { createNostrAssociationUrl, createNostrEvent, + deriveNostrSessionIdentifier, generateNostrKeypair, getNostrEventTags, isNostrEvent, @@ -24,6 +25,9 @@ import { export const REMOTE_WALLET_PAIRING_TTL_MS = 60_000 +const MIN_DAPP_CONNECT_TIMEOUT_MS = 30_000 +const remoteWalletSessionRuntimeOptions = new WeakMap() + type ProtocolVersion = 'legacy' | 'v1' interface RemoteWalletAuthorizationResult { @@ -135,6 +139,11 @@ export interface RemoteWalletPairingSession { status: RemoteWalletPairingStatus } +interface RemoteWalletSessionRuntimeOptions { + identity?: RemoteWalletIdentity + onStatusChange?: (status: RemoteWalletPairingStatus) => void +} + export type RemoteWalletPairingStatus = | 'authorizing' | 'cancelled' @@ -200,6 +209,7 @@ export interface RemoteWalletSignTransactionOutput { } class RemoteWalletRpcClient { + readonly #getIdentity: () => RemoteWalletIdentity readonly #getNextJsonRpcMessageId: () => number readonly #pendingRequests = new Map< number, @@ -215,18 +225,21 @@ class RemoteWalletRpcClient { readonly #walletNostrPubkey: string constructor({ + getIdentity, getNextJsonRpcMessageId, sendNostrEvent, sharedSecret, timeoutMs, walletNostrPubkey, }: { + getIdentity: () => RemoteWalletIdentity getNextJsonRpcMessageId: () => number sendNostrEvent: (content: string, recipientPubkey: string) => void sharedSecret: CryptoKey timeoutMs: number walletNostrPubkey: string }) { + this.#getIdentity = getIdentity this.#getNextJsonRpcMessageId = getNextJsonRpcMessageId this.#sendNostrEvent = sendNostrEvent this.#sharedSecret = sharedSecret @@ -299,6 +312,7 @@ class RemoteWalletRpcClient { async signIn(chain: string, protocolVersion: ProtocolVersion, input?: RemoteWalletSignInInput) { return authorizeRemoteWallet({ chain, + getIdentity: this.#getIdentity, protocolVersion, rpcClient: this, signInPayload: normalizeRemoteWalletSignInPayload(input), @@ -342,22 +356,19 @@ class RemoteWalletRpcClient { } export async function connectRemoteWalletSession({ - identity = getDefaultIdentity(), + identity, onStatusChange, session, timeoutMs = REMOTE_WALLET_PAIRING_TTL_MS, -}: ConnectRemoteWalletSessionOptions) { - if (session.authorizedSession) { - return session.authorizedSession - } - - const relay = await connectToNostrRelay({ identity, onStatusChange, session, timeoutMs }) +}: ConnectRemoteWalletSessionOptions): Promise { + setRemoteWalletSessionRuntimeOptions(session, { identity, onStatusChange }) + await prepareRemoteWalletSession({ session, timeoutMs: getDappConnectTimeoutMs(timeoutMs) }) - session.authorizedSession = relay.authorizedSession - session.cancel = relay.close - session.close = relay.close + if (!session.authorizedSession) { + throw new Error('Remote Wallet session was not prepared') + } - return relay.authorizedSession + return session.authorizedSession } export async function createRemoteWalletSession({ @@ -372,7 +383,8 @@ export async function createRemoteWalletSession({ const chain = chains[0] ?? 'solana:devnet' const { privateKey: dappNostrPrivateKey, publicKey: dappNostrPubkey } = generateNostrKeypair() const relay = normalizeRelayUrl(relayUrl) - const sessionIdentifier = crypto.randomUUID() + const sessionIdentifier = deriveNostrSessionIdentifier(associationPublicKey) + const effectiveTimeoutMs = getDappConnectTimeoutMs(timeoutMs) const session: RemoteWalletPairingSession = { associationKeyPair, cancel() { @@ -385,12 +397,11 @@ export async function createRemoteWalletSession({ connect: (options = {}) => connectRemoteWalletSession({ ...options, session }), dappNostrPrivateKey, dappNostrPubkey, - expiresAt: new Date(Date.now() + timeoutMs).toISOString(), + expiresAt: new Date(Date.now() + effectiveTimeoutMs).toISOString(), pairingUrl: createNostrAssociationUrl({ associationPublicKey, dappNostrPubkey, relayUrl: relay, - sessionIdentifier, }), relayDomain: new URL(relay).host, relayUrl: relay, @@ -398,11 +409,11 @@ export async function createRemoteWalletSession({ status: 'waiting-for-wallet', } - if (connect) { - const authorizedSession = connectRemoteWalletSession({ identity, session, timeoutMs }) + setRemoteWalletSessionRuntimeOptions(session, { identity }) + await prepareRemoteWalletSession({ session, timeoutMs: effectiveTimeoutMs }) - void authorizedSession.catch(() => undefined) - session.authorizedSession = authorizedSession + if (connect) { + void session.authorizedSession?.catch(() => undefined) } return session @@ -410,11 +421,13 @@ export async function createRemoteWalletSession({ async function authorizeRemoteWallet({ chain, + getIdentity, protocolVersion, rpcClient, signInPayload, }: { chain: string + getIdentity: () => RemoteWalletIdentity protocolVersion: ProtocolVersion rpcClient: RemoteWalletRpcClient signInPayload?: RemoteWalletSignInInput @@ -424,7 +437,7 @@ async function authorizeRemoteWallet({ return rpcClient.request('authorize', { ...(protocolVersion === 'legacy' && legacyCluster ? { cluster: legacyCluster } : null), chain, - identity: getDefaultIdentity(), + identity: getIdentity(), ...(signInPayload ? { sign_in_payload: signInPayload } : null), }) } @@ -442,14 +455,37 @@ function chainToLegacyCluster(chain: string) { } } +async function prepareRemoteWalletSession({ + session, + timeoutMs, +}: { + session: RemoteWalletPairingSession + timeoutMs: number +}) { + if (session.authorizedSession) { + return + } + + const relay = await connectToNostrRelay({ + getIdentity: () => remoteWalletSessionRuntimeOptions.get(session)?.identity ?? getDefaultIdentity(), + getOnStatusChange: () => remoteWalletSessionRuntimeOptions.get(session)?.onStatusChange, + session, + timeoutMs, + }) + + session.authorizedSession = relay.authorizedSession + session.cancel = relay.close + session.close = relay.close +} + function connectToNostrRelay({ - identity, - onStatusChange, + getIdentity, + getOnStatusChange, session, timeoutMs, }: { - identity: RemoteWalletIdentity - onStatusChange?: (status: RemoteWalletPairingStatus) => void + getIdentity: () => RemoteWalletIdentity + getOnStatusChange: () => ((status: RemoteWalletPairingStatus) => void) | undefined session: RemoteWalletPairingSession timeoutMs: number }) { @@ -477,7 +513,7 @@ function connectToNostrRelay({ }, timeoutMs) const setStatus = (status: RemoteWalletPairingStatus) => { setSessionStatus(session, status) - onStatusChange?.(status) + getOnStatusChange()?.(status) } const rejectAuthorization = (error: Error) => { if (state.type === 'connected') { @@ -563,10 +599,18 @@ function connectToNostrRelay({ const handleMessage = async (event: MessageEvent) => { const message = parseNostrRelayMessage(event.data) + if (message?.[0] === 'CLOSED' && message[1] === subscriptionId) { + failSession(new Error(`Nostr relay ${session.relayDomain} closed subscription: ${String(message[2] ?? '')}`)) + return + } if (message?.[0] === 'EOSE' && message[1] === subscriptionId) { resolveSubscription() return } + if (message?.[0] === 'OK' && message[2] === false) { + failSession(new Error(`Nostr relay ${session.relayDomain} rejected event: ${String(message[3] ?? '')}`)) + return + } if (message?.[0] !== 'EVENT') { return } @@ -634,6 +678,7 @@ function connectToNostrRelay({ setStatus('authorizing') const rpcClient = new RemoteWalletRpcClient({ + getIdentity, getNextJsonRpcMessageId: () => nextJsonRpcMessageId++, sendNostrEvent, sharedSecret, @@ -648,7 +693,7 @@ function connectToNostrRelay({ ? { cluster: chainToLegacyCluster(session.chain) } : null), chain: session.chain, - identity, + identity: getIdentity(), }) let accounts = authorizationResult.accounts.map((account) => mapRemoteWalletAccount(account, session.chain)) let authToken = authorizationResult.auth_token @@ -700,7 +745,7 @@ function connectToNostrRelay({ break } case 'subscribed': { - if (nostrEvent.content.length !== 0) { + if (nostrEvent.content.length !== 0 || !eventTags.msg?.includes('CONNECT')) { return } @@ -724,6 +769,19 @@ function connectToNostrRelay({ close() } } + const failSession = (error: Error) => { + cleanup() + + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.close() + } + if (!settled) { + reject(error) + return + } + + rejectAuthorization(error) + } socket.addEventListener('close', handleClose) socket.addEventListener('error', handleError) @@ -759,6 +817,10 @@ function getDefaultIdentity(): RemoteWalletIdentity { } } +function getDappConnectTimeoutMs(timeoutMs: number) { + return Math.max(timeoutMs, MIN_DAPP_CONNECT_TIMEOUT_MS) +} + function getRemoteWalletAuthorizedAccount( accounts: readonly RemoteWalletAuthorizedAccount[], encodedAddress: string, @@ -848,6 +910,18 @@ async function parseSessionProtocolVersion(message: Uint8Array, sharedSecret: Cr } } +function setRemoteWalletSessionRuntimeOptions( + session: RemoteWalletPairingSession, + options: RemoteWalletSessionRuntimeOptions, +) { + const currentOptions = remoteWalletSessionRuntimeOptions.get(session) ?? {} + + remoteWalletSessionRuntimeOptions.set(session, { + identity: options.identity ?? currentOptions.identity, + onStatusChange: options.onStatusChange ?? currentOptions.onStatusChange, + }) +} + function setSessionStatus(session: RemoteWalletPairingSession, status: RemoteWalletPairingStatus) { session.status = status } diff --git a/src/node/connect-remote-wallet.ts b/src/node/connect-remote-wallet.ts index 0796901..50d5659 100644 --- a/src/node/connect-remote-wallet.ts +++ b/src/node/connect-remote-wallet.ts @@ -21,6 +21,8 @@ import { import { sendSignedTransaction } from './send-signed-transaction.ts' import { createRemoteWalletAuthorizationAccount, getSolanaRpcUrl, type RemoteWalletSigner } from './signer.ts' +const MIN_WALLET_HELLO_REQ_TIMEOUT_MS = 10_000 + type SessionState = | { lastInboundSequenceNumber: number @@ -60,10 +62,11 @@ export async function connectRemoteWallet({ pairing, signer, timeoutMs, writeEve closed = true socket.close() } + const helloRequestTimeoutMs = Math.max(timeoutMs, MIN_WALLET_HELLO_REQ_TIMEOUT_MS) const timeoutId = setTimeout(() => { closeSocket() - reject(new Error(`Timed out waiting for dapp handshake after ${timeoutMs}ms`)) - }, timeoutMs) + reject(new Error(`Timed out waiting for dapp handshake after ${helloRequestTimeoutMs}ms`)) + }, helloRequestTimeoutMs) const cleanup = () => { clearTimeout(timeoutId) socket.removeEventListener('close', handleClose) @@ -120,11 +123,19 @@ export async function connectRemoteWallet({ pairing, signer, timeoutMs, writeEve try { const message = parseNostrRelayMessage(event.data) + if (message?.[0] === 'CLOSED' && message[1] === subscriptionId) { + failSession(new Error(`Nostr relay ${pairing.relayDomain} closed subscription: ${String(message[2] ?? '')}`)) + return + } if (message?.[0] === 'EOSE' && message[1] === subscriptionId) { sendNostrEvent('', pairing.dappNostrPubkey, [['msg', 'CONNECT']]) writeEvent?.('wallet-joined', {}) return } + if (message?.[0] === 'OK' && message[2] === false) { + failSession(new Error(`Nostr relay ${pairing.relayDomain} rejected event: ${String(message[3] ?? '')}`)) + return + } if (message?.[0] === 'NOTICE') { writeEvent?.('relay-notice', { message: String(message[1] ?? '') }) return @@ -214,6 +225,11 @@ export async function connectRemoteWallet({ pairing, signer, timeoutMs, writeEve reject(error) } } + const failSession = (error: Error) => { + cleanup() + closeSocket() + reject(error) + } const handleOpen = () => { socket.send( JSON.stringify(['REQ', subscriptionId, { '#d': [pairing.sessionIdentifier], kinds: [NOSTR_EVENT_KIND_MWA] }]), diff --git a/src/protocol/association-url.ts b/src/protocol/association-url.ts index 22e16cb..7bea8e6 100644 --- a/src/protocol/association-url.ts +++ b/src/protocol/association-url.ts @@ -3,32 +3,46 @@ import { base64UrlToBytes, bytesToBase64Url, bytesToHex } from './encoding.ts' import type { PairingDetails } from './types.ts' export interface CreateNostrAssociationUrlInput { + associationMode?: 'local' | 'remote' associationPublicKey: Uint8Array dappNostrPubkey: string relayUrl: string - sessionIdentifier: string } export function createNostrAssociationUrl(input: CreateNostrAssociationUrlInput) { const relayUrl = normalizeRelayUrl(input.relayUrl) - const url = new URL('solana-wallet:/v1/associate/remote/nostr') + const associationMode = input.associationMode ?? 'remote' + const url = new URL(`solana-wallet:/v1/associate/${associationMode}/nostr`) url.searchParams.set('association', bytesToBase64Url(input.associationPublicKey)) url.searchParams.set('pubkey', input.dappNostrPubkey) - url.searchParams.set('relay', relayUrl) - url.searchParams.set('session', input.sessionIdentifier) - url.searchParams.set('v', 'v1') + url.searchParams.set('relay', new URL(relayUrl).host) + url.searchParams.set('v', '1') return url.toString() } +export function deriveNostrSessionIdentifier(associationPublicKey: Uint8Array) { + return bytesToHex(sha256(associationPublicKey)) +} + export function parseNostrAssociationUrl(pairingUrl: string): PairingDetails { const url = new URL(pairingUrl) const association = url.searchParams.get('association') const dappNostrPubkey = url.searchParams.get('pubkey') ?? url.searchParams.get('dapp') + const legacySessionIdentifier = url.searchParams.get('session') const relay = url.searchParams.get('relay') + const pathParts = url.pathname.split('/') + const associationMode = pathParts[3] - if (url.protocol !== 'solana-wallet:' || url.pathname !== '/v1/associate/remote/nostr') { + if ( + url.protocol !== 'solana-wallet:' || + pathParts.length !== 5 || + pathParts[1] !== 'v1' || + pathParts[2] !== 'associate' || + (associationMode !== 'local' && associationMode !== 'remote') || + pathParts[4] !== 'nostr' + ) { throw new Error('Unsupported pairing URL') } if (!association) { @@ -42,14 +56,20 @@ export function parseNostrAssociationUrl(pairingUrl: string): PairingDetails { } const associationPublicKey = base64UrlToBytes(association) + const sessionIdentifier = deriveNostrSessionIdentifier(associationPublicKey) const relayUrl = normalizeRelayUrl(relay) + if (legacySessionIdentifier && legacySessionIdentifier !== sessionIdentifier) { + throw new Error('Pairing URL session does not match derived session identifier') + } + return { + associationMode, associationPublicKey, dappNostrPubkey, relayDomain: new URL(relayUrl).host, relayUrl, - sessionIdentifier: url.searchParams.get('session') ?? bytesToHex(sha256(associationPublicKey)), + sessionIdentifier, } } @@ -64,5 +84,7 @@ function normalizeRelayUrl(relay: string) { } function isLocalRelayHost(hostname: string) { - return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost' + const normalizedHostname = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname + + return normalizedHostname === '127.0.0.1' || normalizedHostname === '::1' || normalizedHostname === 'localhost' } diff --git a/src/protocol/types.ts b/src/protocol/types.ts index 8a49b4a..dbc406d 100644 --- a/src/protocol/types.ts +++ b/src/protocol/types.ts @@ -1,4 +1,5 @@ export interface PairingDetails { + associationMode: 'local' | 'remote' associationPublicKey: Uint8Array dappNostrPubkey: string relayDomain: string diff --git a/test/browser/create-remote-wallet-session.test.ts b/test/browser/create-remote-wallet-session.test.ts index de919ef..ba644a3 100644 --- a/test/browser/create-remote-wallet-session.test.ts +++ b/test/browser/create-remote-wallet-session.test.ts @@ -1,5 +1,22 @@ import { describe, expect, test } from 'bun:test' -import { createRemoteWalletSession } from '../../src/browser/index.ts' +import { + createRemoteWalletSession, + type RemoteWalletIdentity, + type RemoteWalletPairingSession, +} from '../../src/browser/index.ts' +import { + base64ToBytes, + bytesToBase64, + concatBytes, + createNostrEvent, + decryptMessage, + deriveSharedSecret, + ENCODED_PUBLIC_KEY_LENGTH_BYTES, + encryptMessage, + generateNostrKeypair, + type NostrEvent, + parseNostrAssociationUrl, +} from '../../src/protocol/index.ts' class MockWebSocket extends EventTarget { static readonly CLOSED = 3 @@ -26,52 +43,399 @@ class MockWebSocket extends EventTarget { } describe('createRemoteWalletSession', () => { - test('creates a pairing URL and cancel function', async () => { - const session = await createRemoteWalletSession({ - relayUrl: 'wss://relay.example.com', + test('creates a pairing URL after relay subscription', async () => { + await withMockWebSocket(async ({ sockets }) => { + const sessionPromise = createRemoteWalletSession({ + relayUrl: 'wss://relay.example.com', + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + + const session = await sessionPromise + + expect(session.pairingUrl.startsWith('solana-wallet:/v1/associate/remote/nostr')).toBe(true) + expect(typeof session.cancel).toBe('function') }) + }) + + test('waits for subscription before resolving the pairing session', async () => { + await withMockWebSocket(async ({ sockets }) => { + let resolved = false + const sessionPromise = createRemoteWalletSession({ + relayUrl: 'wss://relay.example.com', + }).then((session) => { + resolved = true + + return session + }) + const socket = await waitForSocket(sockets) + + socket.open() + await waitForMicrotasks() + + expect(resolved).toBe(false) + + const request = getSubscriptionRequest(socket) - expect(session.pairingUrl.startsWith('solana-wallet:/v1/associate/remote/nostr')).toBe(true) - expect(typeof session.cancel).toBe('function') + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + await sessionPromise + + expect(resolved).toBe(true) + }) }) - test('rejects pending authorization when the relay closes after subscription', async () => { - const originalWebSocket = globalThis.WebSocket - const sockets: MockWebSocket[] = [] + test('subscribes with the derived session identifier', async () => { + await withMockWebSocket(async ({ sockets }) => { + const sessionPromise = createRemoteWalletSession({ + relayUrl: 'wss://relay.example.com', + }) + const socket = await waitForSocket(sockets) - globalThis.WebSocket = class extends MockWebSocket { - constructor() { - super() - sockets.push(this) - } - } as unknown as typeof WebSocket + socket.open() + + const request = getSubscriptionRequest(socket) - try { - const session = await createRemoteWalletSession({ + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + + const session = await sessionPromise + const pairing = parseNostrAssociationUrl(session.pairingUrl) + + expect(request[2]['#d']).toEqual([pairing.sessionIdentifier]) + expect(request[2]['#d']).toEqual([session.sessionIdentifier]) + }) + }) + + test('uses the enforced connect timeout for the pairing expiry', async () => { + await withMockWebSocket(async ({ sockets }) => { + const before = Date.now() + const sessionPromise = createRemoteWalletSession({ relayUrl: 'wss://relay.example.com', + timeoutMs: 1, }) - const authorizedSession = session.connect({ timeoutMs: 10_000 }) - const socket = sockets[0] + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + + const session = await sessionPromise + const expiryDelayMs = Date.parse(session.expiresAt) - before + + expect(expiryDelayMs).toBeGreaterThanOrEqual(30_000) + expect(expiryDelayMs).toBeLessThan(35_000) + }) + }) + + test('ignores empty startup events until a valid CONNECT arrives', async () => { + await withMockWebSocket(async ({ sockets }) => { + const sessionPromise = createRemoteWalletSession({ + relayUrl: 'wss://relay.example.com', + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + + const session = await sessionPromise + const walletNostrKeypair = generateNostrKeypair() + const nonConnectEvent = createNostrEvent( + '', + 20012, + [ + ['d', session.sessionIdentifier], + ['p', session.dappNostrPubkey], + ], + walletNostrKeypair.privateKey, + ) + + dispatchNostrEvent(socket, request[1], nonConnectEvent) + await waitForMicrotasks() - if (!socket) { - throw new Error('Expected session to open a WebSocket') + expect(socket.sentMessages).toHaveLength(1) + + const connectEvent = createNostrEvent( + '', + 20012, + [ + ['d', session.sessionIdentifier], + ['p', session.dappNostrPubkey], + ['msg', 'CONNECT'], + ], + walletNostrKeypair.privateKey, + ) + + dispatchNostrEvent(socket, request[1], connectEvent) + await waitForSentMessages(socket, 2) + + const helloRequest = JSON.parse(socket.sentMessages[1] ?? '[]') as ['EVENT', NostrEvent] + + expect(helloRequest[0]).toBe('EVENT') + expect(helloRequest[1].content.length).toBeGreaterThan(0) + }) + }) + + test('uses the session identity for sign in reauthorization', async () => { + await withMockWebSocket(async ({ sockets }) => { + const identity: RemoteWalletIdentity = { + name: 'Spec Test Dapp', + uri: 'https://dapp.example.com', } + const sessionPromise = createRemoteWalletSession({ + identity, + relayUrl: 'wss://relay.example.com', + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + + const session = await sessionPromise + const authorizedSessionPromise = session.connect() + const walletNostrKeypair = generateNostrKeypair() + const connectEvent = createWalletNostrEvent(session, walletNostrKeypair.privateKey, '', [['msg', 'CONNECT']]) + + dispatchNostrEvent(socket, request[1], connectEvent) + await waitForSentMessages(socket, 2) + + const helloRequest = getSentNostrEvent(socket, 1) + const helloResponse = await createWalletHelloResponse(session, helloRequest) + const helloResponseEvent = createWalletNostrEvent(session, walletNostrKeypair.privateKey, helloResponse.content) + + dispatchNostrEvent(socket, request[1], helloResponseEvent) + await waitForSentMessages(socket, 3) + + const accountAddress = bytesToBase64(new Uint8Array(32).fill(1)) + const authorizeRequest = await decryptJsonRpcRequest(getSentNostrEvent(socket, 2), helloResponse.sharedSecret) + + expect(authorizeRequest.method).toBe('authorize') + expect(authorizeRequest.params.identity).toEqual(identity) + + const authorizeResponseEvent = await createWalletJsonRpcResponseEvent({ + id: authorizeRequest.id, + result: { + accounts: [{ address: accountAddress }], + auth_token: 'auth-token', + }, + sequenceNumber: 2, + session, + sharedSecret: helloResponse.sharedSecret, + walletNostrPrivateKey: walletNostrKeypair.privateKey, + }) + + dispatchNostrEvent(socket, request[1], authorizeResponseEvent) + + const authorizedSession = await authorizedSessionPromise + const signInPromise = authorizedSession.signIn({ domain: 'dapp.example.com' }) + + await waitForSentMessages(socket, 4) + + const signInAuthorizeRequest = await decryptJsonRpcRequest( + getSentNostrEvent(socket, 3), + helloResponse.sharedSecret, + ) + + expect(signInAuthorizeRequest.method).toBe('authorize') + expect(signInAuthorizeRequest.params.identity).toEqual(identity) + expect(signInAuthorizeRequest.params.sign_in_payload).toMatchObject({ + domain: 'dapp.example.com', + }) + + const signInResponseEvent = await createWalletJsonRpcResponseEvent({ + id: signInAuthorizeRequest.id, + result: { + accounts: [{ address: accountAddress }], + auth_token: 'sign-in-auth-token', + sign_in_result: { + address: accountAddress, + signature: bytesToBase64(new Uint8Array(64).fill(2)), + signature_type: 'ed25519', + signed_message: bytesToBase64(new TextEncoder().encode('signed message')), + }, + }, + sequenceNumber: 3, + session, + sharedSecret: helloResponse.sharedSecret, + walletNostrPrivateKey: walletNostrKeypair.privateKey, + }) + + dispatchNostrEvent(socket, request[1], signInResponseEvent) + + await signInPromise + authorizedSession.close() + }) + }) + + test('rejects pending authorization when the relay closes after subscription', async () => { + await withMockWebSocket(async ({ sockets }) => { + const sessionPromise = createRemoteWalletSession({ + relayUrl: 'wss://relay.example.com', + }) + const socket = await waitForSocket(sockets) socket.open() - const request = JSON.parse(socket.sentMessages[0] ?? '[]') as [string, string] + const request = getSubscriptionRequest(socket) socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) - socket.close() + + const session = await sessionPromise + const authorizedSession = session.connect({ timeoutMs: 10_000 }) + + socket.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify(['CLOSED', request[1], 'error: subscription closed']), + }), + ) await expect( Promise.race([ authorizedSession, new Promise((_, reject) => setTimeout(() => reject(new Error('Authorization did not reject')), 50)), ]), - ).rejects.toThrow('Nostr relay relay.example.com closed') - } finally { - globalThis.WebSocket = originalWebSocket - } + ).rejects.toThrow('Nostr relay relay.example.com closed subscription: error: subscription closed') + }) }) }) + +interface JsonRpcRequest { + id: number + method: string + params: { + identity?: unknown + sign_in_payload?: unknown + } +} + +async function createWalletHelloResponse(session: RemoteWalletPairingSession, helloRequest: NostrEvent) { + const dappEcdhPublicKey = base64ToBytes(helloRequest.content).slice(0, ENCODED_PUBLIC_KEY_LENGTH_BYTES) + const pairing = parseNostrAssociationUrl(session.pairingUrl) + const walletEcdhKeyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) + const sharedSecret = await deriveSharedSecret({ + associationPublicKey: pairing.associationPublicKey, + dappEcdhPublicKey, + walletEcdhPrivateKey: walletEcdhKeyPair.privateKey, + }) + const sessionProperties = await encryptMessage(JSON.stringify({ v: 1 }), 1, sharedSecret) + const walletEcdhPublicKey = new Uint8Array(await crypto.subtle.exportKey('raw', walletEcdhKeyPair.publicKey)) + + return { + content: bytesToBase64(concatBytes(walletEcdhPublicKey, sessionProperties)), + sharedSecret, + } +} + +async function createWalletJsonRpcResponseEvent({ + id, + result, + sequenceNumber, + session, + sharedSecret, + walletNostrPrivateKey, +}: { + id: number + result: unknown + sequenceNumber: number + session: RemoteWalletPairingSession + sharedSecret: CryptoKey + walletNostrPrivateKey: Uint8Array +}) { + const response = await encryptMessage(JSON.stringify({ id, jsonrpc: '2.0', result }), sequenceNumber, sharedSecret) + + return createWalletNostrEvent(session, walletNostrPrivateKey, bytesToBase64(response)) +} + +function createWalletNostrEvent( + session: RemoteWalletPairingSession, + walletNostrPrivateKey: Uint8Array, + content: string, + extraTags: string[][] = [], +) { + return createNostrEvent( + content, + 20012, + [['d', session.sessionIdentifier], ['p', session.dappNostrPubkey], ...extraTags], + walletNostrPrivateKey, + ) +} + +async function decryptJsonRpcRequest(event: NostrEvent, sharedSecret: CryptoKey) { + return JSON.parse(await decryptMessage(base64ToBytes(event.content), sharedSecret)) as JsonRpcRequest +} + +function dispatchNostrEvent(socket: MockWebSocket, subscriptionId: string, event: NostrEvent) { + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', subscriptionId, event]) })) +} + +function getSentNostrEvent(socket: MockWebSocket, messageIndex: number) { + const message = JSON.parse(socket.sentMessages[messageIndex] ?? '[]') as ['EVENT', NostrEvent] + + expect(message[0]).toBe('EVENT') + + return message[1] +} + +function getSubscriptionRequest(socket: MockWebSocket) { + return JSON.parse(socket.sentMessages[0] ?? '[]') as ['REQ', string, { '#d': string[]; kinds: number[] }] +} + +async function waitForMicrotasks() { + await Promise.resolve() + await Promise.resolve() +} + +async function waitForSentMessages(socket: MockWebSocket, count: number) { + for (let index = 0; index < 20; index++) { + if (socket.sentMessages.length >= count) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + throw new Error(`Expected WebSocket to send ${count} messages`) +} + +async function waitForSocket(sockets: MockWebSocket[]) { + for (let index = 0; index < 20; index++) { + const socket = sockets[0] + + if (socket) { + return socket + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + throw new Error('Expected session to open a WebSocket') +} + +async function withMockWebSocket(testFn: (context: { sockets: MockWebSocket[] }) => Promise) { + const originalWebSocket = globalThis.WebSocket + const sockets: MockWebSocket[] = [] + + globalThis.WebSocket = class extends MockWebSocket { + constructor() { + super() + sockets.push(this) + } + } as unknown as typeof WebSocket + + try { + await testFn({ sockets }) + } finally { + globalThis.WebSocket = originalWebSocket + } +} diff --git a/test/node/connect-remote-wallet.test.ts b/test/node/connect-remote-wallet.test.ts index c9dfc58..86a7e94 100644 --- a/test/node/connect-remote-wallet.test.ts +++ b/test/node/connect-remote-wallet.test.ts @@ -1,5 +1,37 @@ import { describe, expect, test } from 'bun:test' -import { createRemoteWalletSigner } from '../../src/node/index.ts' +import { connectRemoteWallet, createRemoteWalletSigner } from '../../src/node/index.ts' +import { + createNostrEvent, + deriveNostrSessionIdentifier, + generateNostrKeypair, + getNostrEventTags, + type NostrEvent, + type PairingDetails, +} from '../../src/protocol/index.ts' + +class MockWebSocket extends EventTarget { + static readonly CLOSED = 3 + static readonly CLOSING = 2 + static readonly CONNECTING = 0 + static readonly OPEN = 1 + + readonly sentMessages: string[] = [] + readyState = MockWebSocket.CONNECTING + + close() { + this.readyState = MockWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + open() { + this.readyState = MockWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + send(message: string) { + this.sentMessages.push(message) + } +} describe('connectRemoteWallet', () => { test('exposes signer state for connection orchestration', () => { @@ -7,4 +39,223 @@ describe('connectRemoteWallet', () => { expect(signer.account.label).toBe('Agent Remote Wallet') }) + + test('sends a spec-shaped CONNECT event after subscription', async () => { + await withMockWebSocket(async ({ sockets }) => { + const context = createTestContext() + const connection = connectRemoteWallet({ + pairing: context.pairing, + signer: createRemoteWalletSigner({ label: 'Agent Remote Wallet' }), + timeoutMs: 1, + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + await waitForSentMessages(socket, 2) + + const connectMessage = JSON.parse(socket.sentMessages[1] ?? '[]') as ['EVENT', NostrEvent] + const connectEvent = connectMessage[1] + const tags = getNostrEventTags(connectEvent) + + expect(connectMessage[0]).toBe('EVENT') + expect(connectEvent.content).toBe('') + expect(tags.d).toEqual([context.pairing.sessionIdentifier]) + expect(tags.msg).toEqual(['CONNECT']) + expect(tags.p).toEqual([context.pairing.dappNostrPubkey]) + + dispatchNostrEvent(socket, request[1], createSessionEndEvent(context, connectEvent.pubkey)) + await connection + }) + }) + + test('ignores SESSION_END from an unexpected dapp pubkey', async () => { + await withMockWebSocket(async ({ sockets }) => { + const context = createTestContext() + const connection = connectRemoteWallet({ + pairing: context.pairing, + signer: createRemoteWalletSigner({ label: 'Agent Remote Wallet' }), + timeoutMs: 1, + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + await waitForSentMessages(socket, 2) + + const connectMessage = JSON.parse(socket.sentMessages[1] ?? '[]') as ['EVENT', NostrEvent] + const wrongDappKeypair = generateNostrKeypair() + const spoofedSessionEnd = createNostrEvent( + '', + 20012, + [ + ['d', context.pairing.sessionIdentifier], + ['p', connectMessage[1].pubkey], + ['msg', 'SESSION_END'], + ], + wrongDappKeypair.privateKey, + ) + + dispatchNostrEvent(socket, request[1], spoofedSessionEnd) + await waitForMicrotasks() + + expect(socket.readyState).toBe(MockWebSocket.OPEN) + + dispatchNostrEvent(socket, request[1], createSessionEndEvent(context, connectMessage[1].pubkey)) + await connection + }) + }) + + test('rejects when the relay closes the active subscription', async () => { + await withMockWebSocket(async ({ sockets }) => { + const context = createTestContext() + const connection = connectRemoteWallet({ + pairing: context.pairing, + signer: createRemoteWalletSigner({ label: 'Agent Remote Wallet' }), + timeoutMs: 1, + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify(['CLOSED', request[1], 'error: subscription closed']), + }), + ) + + await expect(connection).rejects.toThrow( + 'Nostr relay relay.example.com closed subscription: error: subscription closed', + ) + }) + }) + + test('rejects when the relay rejects a sent event', async () => { + await withMockWebSocket(async ({ sockets }) => { + const context = createTestContext() + const connection = connectRemoteWallet({ + pairing: context.pairing, + signer: createRemoteWalletSigner({ label: 'Agent Remote Wallet' }), + timeoutMs: 1, + }) + const socket = await waitForSocket(sockets) + + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) + await waitForSentMessages(socket, 2) + + const connectMessage = JSON.parse(socket.sentMessages[1] ?? '[]') as ['EVENT', NostrEvent] + + socket.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify(['OK', connectMessage[1].id, false, 'blocked: write denied']), + }), + ) + + await expect(connection).rejects.toThrow('Nostr relay relay.example.com rejected event: blocked: write denied') + }) + }) }) + +interface TestContext { + dappNostrPrivateKey: Uint8Array + pairing: PairingDetails +} + +function createSessionEndEvent(context: TestContext, walletNostrPubkey: string) { + return createNostrEvent( + '', + 20012, + [ + ['d', context.pairing.sessionIdentifier], + ['p', walletNostrPubkey], + ['msg', 'SESSION_END'], + ], + context.dappNostrPrivateKey, + ) +} + +function createTestContext(): TestContext { + const associationPublicKey = new Uint8Array(65).fill(7) + const dappNostrKeypair = generateNostrKeypair() + + return { + dappNostrPrivateKey: dappNostrKeypair.privateKey, + pairing: { + associationMode: 'remote', + associationPublicKey, + dappNostrPubkey: dappNostrKeypair.publicKey, + relayDomain: 'relay.example.com', + relayUrl: 'wss://relay.example.com', + sessionIdentifier: deriveNostrSessionIdentifier(associationPublicKey), + }, + } +} + +function dispatchNostrEvent(socket: MockWebSocket, subscriptionId: string, event: NostrEvent) { + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', subscriptionId, event]) })) +} + +function getSubscriptionRequest(socket: MockWebSocket) { + return JSON.parse(socket.sentMessages[0] ?? '[]') as ['REQ', string, { '#d': string[]; kinds: number[] }] +} + +async function waitForMicrotasks() { + await Promise.resolve() + await Promise.resolve() +} + +async function waitForSentMessages(socket: MockWebSocket, count: number) { + for (let index = 0; index < 20; index++) { + if (socket.sentMessages.length >= count) { + return + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + throw new Error(`Expected WebSocket to send ${count} messages`) +} + +async function waitForSocket(sockets: MockWebSocket[]) { + for (let index = 0; index < 20; index++) { + const socket = sockets[0] + + if (socket) { + return socket + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + throw new Error('Expected wallet to open a WebSocket') +} + +async function withMockWebSocket(testFn: (context: { sockets: MockWebSocket[] }) => Promise) { + const originalWebSocket = globalThis.WebSocket + const sockets: MockWebSocket[] = [] + + globalThis.WebSocket = class extends MockWebSocket { + constructor() { + super() + sockets.push(this) + } + } as unknown as typeof WebSocket + + try { + await testFn({ sockets }) + } finally { + globalThis.WebSocket = originalWebSocket + } +} diff --git a/test/protocol/association-url.test.ts b/test/protocol/association-url.test.ts index 705f2fd..3093307 100644 --- a/test/protocol/association-url.test.ts +++ b/test/protocol/association-url.test.ts @@ -1,23 +1,50 @@ import { describe, expect, test } from 'bun:test' -import { createNostrAssociationUrl, parseNostrAssociationUrl } from '../../src/protocol/index.ts' +import { + bytesToBase64Url, + createNostrAssociationUrl, + deriveNostrSessionIdentifier, + parseNostrAssociationUrl, +} from '../../src/protocol/index.ts' describe('Nostr association URL', () => { - test('round trips required pairing details', () => { + test('emits a spec-shaped remote pairing URL with a derived session identifier', () => { const associationPublicKey = new Uint8Array(65).fill(7) + const sessionIdentifier = deriveNostrSessionIdentifier(associationPublicKey) const url = createNostrAssociationUrl({ associationPublicKey, dappNostrPubkey: 'f'.repeat(64), relayUrl: 'wss://relay.example.com/nostr', - sessionIdentifier: 'session-123', }) + const parsedUrl = new URL(url) expect(url.startsWith('solana-wallet:/v1/associate/remote/nostr')).toBe(true) - expect(new URL(url).searchParams.get('relay')).toBe('wss://relay.example.com/nostr') + expect(parsedUrl.searchParams.get('relay')).toBe('relay.example.com') + expect(parsedUrl.searchParams.get('session')).toBeNull() + expect(parsedUrl.searchParams.get('v')).toBe('1') + expect(sessionIdentifier).toMatch(/^[0-9a-f]{64}$/u) expect(parseNostrAssociationUrl(url)).toMatchObject({ + associationMode: 'remote', dappNostrPubkey: 'f'.repeat(64), relayDomain: 'relay.example.com', - relayUrl: 'wss://relay.example.com/nostr', - sessionIdentifier: 'session-123', + relayUrl: 'wss://relay.example.com', + sessionIdentifier, + }) + }) + + test('round trips local association mode', () => { + const associationPublicKey = new Uint8Array(65).fill(3) + const url = createNostrAssociationUrl({ + associationMode: 'local', + associationPublicKey, + dappNostrPubkey: 'a'.repeat(64), + relayUrl: 'ws://127.0.0.1:8080', + }) + + expect(url.startsWith('solana-wallet:/v1/associate/local/nostr')).toBe(true) + expect(parseNostrAssociationUrl(url)).toMatchObject({ + associationMode: 'local', + relayDomain: '127.0.0.1:8080', + relayUrl: 'ws://127.0.0.1:8080', }) }) @@ -27,12 +54,48 @@ describe('Nostr association URL', () => { associationPublicKey, dappNostrPubkey: 'f'.repeat(64), relayUrl: 'ws://127.0.0.1:8080', - sessionIdentifier: 'session-123', }) + expect(new URL(url).searchParams.get('relay')).toBe('127.0.0.1:8080') expect(parseNostrAssociationUrl(url)).toMatchObject({ relayDomain: '127.0.0.1:8080', relayUrl: 'ws://127.0.0.1:8080', }) }) + + test('uses ws for IPv6 loopback relay hosts without an explicit scheme', () => { + const associationPublicKey = new Uint8Array(65).fill(7) + const url = createNostrAssociationUrl({ + associationPublicKey, + dappNostrPubkey: 'f'.repeat(64), + relayUrl: 'ws://[::1]:8080', + }) + + expect(new URL(url).searchParams.get('relay')).toBe('[::1]:8080') + expect(parseNostrAssociationUrl(url)).toMatchObject({ + relayDomain: '[::1]:8080', + relayUrl: 'ws://[::1]:8080', + }) + }) + + test('accepts legacy URLs when the session query matches the derived identifier', () => { + const associationPublicKey = new Uint8Array(65).fill(9) + const association = bytesToBase64Url(associationPublicKey) + const sessionIdentifier = deriveNostrSessionIdentifier(associationPublicKey) + const url = `solana-wallet:/v1/associate/remote/nostr?association=${association}&relay=wss%3A%2F%2Frelay.example.com%2Fnostr&pubkey=${'b'.repeat(64)}&session=${sessionIdentifier}` + + expect(parseNostrAssociationUrl(url)).toMatchObject({ + relayDomain: 'relay.example.com', + relayUrl: 'wss://relay.example.com/nostr', + sessionIdentifier, + }) + }) + + test('rejects legacy URLs with a conflicting session query', () => { + const associationPublicKey = new Uint8Array(65).fill(9) + const association = bytesToBase64Url(associationPublicKey) + const url = `solana-wallet:/v1/associate/remote/nostr?association=${association}&relay=relay.example.com&pubkey=${'b'.repeat(64)}&session=session-123` + + expect(() => parseNostrAssociationUrl(url)).toThrow('Pairing URL session does not match derived session identifier') + }) }) diff --git a/test/react/use-remote-wallet-pairing.test.ts b/test/react/use-remote-wallet-pairing.test.ts index 9eed6cd..2de5ef1 100644 --- a/test/react/use-remote-wallet-pairing.test.ts +++ b/test/react/use-remote-wallet-pairing.test.ts @@ -16,28 +16,64 @@ describe('React pairing helpers', () => { `../../src/react/use-remote-wallet-pairing.ts?test=${crypto.randomUUID()}` ) - const firstHook = react.render(() => useRemoteWalletPairing()) - const firstSession = await firstHook.startPairing() - const firstCancel = mock(() => {}) - firstSession.cancel = firstCancel + await withMockWebSocket(async ({ sockets }) => { + const firstHook = react.render(() => useRemoteWalletPairing('wss://relay.example.com')) + const firstSessionPromise = firstHook.startPairing() + const firstSocket = await waitForSocket(sockets, 0) - const secondHook = react.render(() => useRemoteWalletPairing()) - const secondSession = await secondHook.startPairing() - const secondCancel = mock(() => {}) - secondSession.cancel = secondCancel + resolveSubscription(firstSocket) - expect(firstCancel).toHaveBeenCalledTimes(1) + const firstSession = await firstSessionPromise + const firstCancel = mock(() => {}) + firstSession.cancel = firstCancel - react.render(() => useRemoteWalletPairing()) - react.unmount() + const secondHook = react.render(() => useRemoteWalletPairing('wss://relay.example.com')) + const secondSessionPromise = secondHook.startPairing() + const secondSocket = await waitForSocket(sockets, 1) - expect(secondCancel).toHaveBeenCalledTimes(1) + resolveSubscription(secondSocket) + + const secondSession = await secondSessionPromise + const secondCancel = mock(() => {}) + secondSession.cancel = secondCancel + + expect(firstCancel).toHaveBeenCalledTimes(1) + + react.render(() => useRemoteWalletPairing('wss://relay.example.com')) + react.unmount() + + expect(secondCancel).toHaveBeenCalledTimes(1) + }) } finally { mock.restore() } }) }) +class MockWebSocket extends EventTarget { + static readonly CLOSED = 3 + static readonly CLOSING = 2 + static readonly CONNECTING = 0 + static readonly OPEN = 1 + + readonly sentMessages: string[] = [] + readyState = MockWebSocket.CONNECTING + + close() { + this.readyState = MockWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + open() { + this.readyState = MockWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + send(message: string) { + this.sentMessages.push(message) + } +} + type EffectCleanup = undefined | (() => void) type StateUpdater = T | ((previous: T) => T) @@ -110,3 +146,47 @@ function createTestReactRuntime() { }, } } + +function getSubscriptionRequest(socket: MockWebSocket) { + return JSON.parse(socket.sentMessages[0] ?? '[]') as ['REQ', string, { '#d': string[]; kinds: number[] }] +} + +function resolveSubscription(socket: MockWebSocket) { + socket.open() + + const request = getSubscriptionRequest(socket) + + socket.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', request[1]]) })) +} + +async function waitForSocket(sockets: MockWebSocket[], index: number) { + for (let attempts = 0; attempts < 20; attempts++) { + const socket = sockets[index] + + if (socket) { + return socket + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + throw new Error(`Expected pairing to open WebSocket ${index}`) +} + +async function withMockWebSocket(testFn: (context: { sockets: MockWebSocket[] }) => Promise) { + const originalWebSocket = globalThis.WebSocket + const sockets: MockWebSocket[] = [] + + globalThis.WebSocket = class extends MockWebSocket { + constructor() { + super() + sockets.push(this) + } + } as unknown as typeof WebSocket + + try { + await testFn({ sockets }) + } finally { + globalThis.WebSocket = originalWebSocket + } +}