diff --git a/.changeset/funky-coins-know.md b/.changeset/funky-coins-know.md new file mode 100644 index 00000000..aa0e10bc --- /dev/null +++ b/.changeset/funky-coins-know.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: NIP-42 AUTH handler and WebSocket session wiring diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 3b6077eb..c42bc0a8 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -15,6 +15,9 @@ export type IWebSocketAdapter = EventEmitter & { getClientId(): string getClientAddress(): string getSubscriptions(): Map + getChallenge(): string + getAuthenticatedPubkeys(): ReadonlySet + addAuthenticatedPubkey(pubkey: string): void } export interface ICacheAdapter { diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index cd81e8e6..570a50b2 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'crypto' import cluster from 'cluster' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' @@ -5,7 +6,7 @@ import { WebSocket } from 'ws' import { ZodError } from 'zod' import { ContextMetadata, Factory } from '../@types/base' -import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' +import { createAuthChallengeMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' import { IAbortable, IMessageHandler } from '../@types/message-handlers' import { IncomingMessage, OutgoingMessage } from '../@types/messages' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' @@ -32,6 +33,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map + private readonly challenge: string + private readonly authenticatedPubkeys: Set public constructor( private readonly client: WebSocket, @@ -79,6 +82,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter .on(WebSocketAdapterEvent.Message, this.sendMessage.bind(this)) logger('client %s connected from %s', this.clientId, this.clientAddress.address) + + // NIP-42 + this.challenge = randomBytes(32).toString('base64url') + this.authenticatedPubkeys = new Set() + this.sendMessage(createAuthChallengeMessage(this.challenge)) } public getClientId(): string { @@ -141,6 +149,19 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter return new Map(this.subscriptions) } + // NIP-42 + public getChallenge(): string { + return this.challenge + } + + public getAuthenticatedPubkeys(): ReadonlySet { + return new Set(this.authenticatedPubkeys) + } + + public addAuthenticatedPubkey(pubkey: string): void { + this.authenticatedPubkeys.add(pubkey) + } + private async onClientMessage(raw: Buffer) { this.alive = true let abortable = false @@ -241,6 +262,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private onClientClose() { this.alive = false this.subscriptions.clear() + this.authenticatedPubkeys.clear() const handlers = abortableMessageHandlers.get(this.client) if (Array.isArray(handlers) && handlers.length) { diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 273b5b37..b6943725 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -2,6 +2,7 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' import { createSettings } from './settings-factory' +import { AuthMessageHandler } from '../handlers/auth-message-handler' import { CountMessageHandler } from '../handlers/count-message-handler' import { EventMessageHandler } from '../handlers/event-message-handler' import { eventStrategyFactory } from './event-strategy-factory' @@ -45,6 +46,8 @@ export const messageHandlerFactory = return new UnsubscribeMessageHandler(adapter) case MessageType.COUNT: return new CountMessageHandler(adapter, eventRepository, createSettings) + case MessageType.AUTH: + return new AuthMessageHandler(adapter, createSettings) default: throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`) } diff --git a/src/handlers/auth-message-handler.ts b/src/handlers/auth-message-handler.ts new file mode 100644 index 00000000..e3f4f0c1 --- /dev/null +++ b/src/handlers/auth-message-handler.ts @@ -0,0 +1,86 @@ +import { EventKinds, EventTags } from '../constants/base' +import { isEventIdValid, isEventSignatureValid } from '../utils/event' +import { AuthMessage } from '../@types/messages' +import { createCommandResult } from '../utils/messages' +import { createLogger } from '../factories/logger-factory' +import { IMessageHandler } from '../@types/message-handlers' +import { IWebSocketAdapter } from '../@types/adapters' +import { Settings } from '../@types/settings' +import { WebSocketAdapterEvent } from '../constants/adapter' + +const logger = createLogger('auth-message-handler') + +const AUTH_EVENT_KIND = EventKinds.AUTH // 22242 +const MAX_TIMESTAMP_DELTA_SECONDS = 600 // 10 minutes + +export class AuthMessageHandler implements IMessageHandler { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly settings: () => Settings, + ) {} + + public async handleMessage(message: AuthMessage): Promise { + const event = message[1] + + if (event.kind !== AUTH_EVENT_KIND) { + this.sendResult(event.id, false, 'invalid: auth event must be kind 22242') + return + } + + if (!(await isEventIdValid(event))) { + this.sendResult(event.id, false, 'invalid: event id does not match') + return + } + + if (!(await isEventSignatureValid(event))) { + this.sendResult(event.id, false, 'invalid: event signature verification failed') + return + } + + const now = Math.floor(Date.now() / 1000) + const delta = Math.abs(now - event.created_at) + if (delta > MAX_TIMESTAMP_DELTA_SECONDS) { + this.sendResult(event.id, false, 'invalid: created_at is too far from the current time') + return + } + + const challengeTag = event.tags.find( + (tag) => tag.length >= 2 && tag[0] === EventTags.Challenge, + ) + if (!challengeTag || challengeTag[1] !== this.webSocket.getChallenge()) { + this.sendResult(event.id, false, 'invalid: challenge does not match') + return + } + + const relayTag = event.tags.find( + (tag) => tag.length >= 2 && tag[0] === EventTags.AuthRelay, + ) + const relayUrl = this.settings().info.relay_url + if (!relayTag || !this.isRelayUrlMatch(relayTag[1], relayUrl)) { + this.sendResult(event.id, false, 'invalid: relay url does not match') + return + } + + logger('client %s authenticated as %s', this.webSocket.getClientId(), event.pubkey) + this.webSocket.addAuthenticatedPubkey(event.pubkey) + this.sendResult(event.id, true, '') + } + + private sendResult(eventId: string, success: boolean, message: string): void { + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(eventId, success, message), + ) + } + + // NIP-42 says domain-match is sufficient for relay URL comparison + private isRelayUrlMatch(clientRelay: string, serverRelay: string): boolean { + try { + const clientHost = new URL(clientRelay).hostname.toLowerCase() + const serverHost = new URL(serverRelay).hostname.toLowerCase() + return clientHost === serverHost + } catch { + return false + } + } +} diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 7139cbad..c58efeb9 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -244,6 +244,11 @@ export class EventMessageHandler implements IMessageHandler { if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event) || isWelcomeRumorEvent(event)) { return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap` } + + // NIP-42: auth events must use the AUTH message type + if (event.kind === EventKinds.AUTH) { + return 'invalid: auth events must be sent using the AUTH message type' + } } protected async isBlockedByRequestToVanish(event: Event): Promise { diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index be535c38..f3d48066 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -77,6 +77,9 @@ describe('WebSocketAdapter', () => { slidingWindowRateLimiter, settingsFactory, ) + + // Reset send history so existing tests see a clean slate + client.send.resetHistory() }) afterEach(() => { @@ -603,4 +606,94 @@ describe('WebSocketAdapter', () => { ipv6Adapter.removeAllListeners() }) }) + + describe('NIP-42 authentication', () => { + it('sends AUTH challenge message on construction', () => { + const freshClient = { + on: sandbox.stub().returnsThis(), + send: sandbox.stub(), + close: sandbox.stub(), + ping: sandbox.stub(), + pong: sandbox.stub(), + readyState: WebSocket.OPEN, + removeAllListeners: sandbox.stub(), + } + const freshAdapter = new WebSocketAdapter( + freshClient as any, + request, + webSocketServer as any, + createMessageHandler, + slidingWindowRateLimiter, + settingsFactory, + ) + + expect(freshClient.send).to.have.been.calledOnce + const sent = JSON.parse(freshClient.send.firstCall.args[0]) + expect(sent[0]).to.equal('AUTH') + expect(sent[1]).to.be.a('string') + expect(sent[1].length).to.be.greaterThan(0) + freshAdapter.removeAllListeners() + }) + + it('getChallenge returns a non-empty string', () => { + const challenge = adapter.getChallenge() + expect(challenge).to.be.a('string') + expect(challenge.length).to.be.greaterThan(0) + }) + + it('getChallenge returns consistent value for the same adapter', () => { + const c1 = adapter.getChallenge() + const c2 = adapter.getChallenge() + expect(c1).to.equal(c2) + }) + + it('getAuthenticatedPubkeys returns empty set initially', () => { + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(0) + }) + + it('addAuthenticatedPubkey adds a pubkey', () => { + const pubkey = 'a'.repeat(64) + adapter.addAuthenticatedPubkey(pubkey) + + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(1) + expect(pubkeys.has(pubkey)).to.be.true + }) + + it('addAuthenticatedPubkey supports multiple pubkeys', () => { + const pk1 = 'a'.repeat(64) + const pk2 = 'b'.repeat(64) + adapter.addAuthenticatedPubkey(pk1) + adapter.addAuthenticatedPubkey(pk2) + + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(2) + expect(pubkeys.has(pk1)).to.be.true + expect(pubkeys.has(pk2)).to.be.true + }) + + it('addAuthenticatedPubkey deduplicates same pubkey', () => { + const pubkey = 'a'.repeat(64) + adapter.addAuthenticatedPubkey(pubkey) + adapter.addAuthenticatedPubkey(pubkey) + + const pubkeys = adapter.getAuthenticatedPubkeys() + expect(pubkeys.size).to.equal(1) + }) + + it('generates different challenges for different adapters', () => { + const adapter2 = new WebSocketAdapter( + client, + request, + webSocketServer as any, + createMessageHandler, + slidingWindowRateLimiter, + settingsFactory, + ) + + expect(adapter.getChallenge()).not.to.equal(adapter2.getChallenge()) + adapter2.removeAllListeners() + }) + }) }) diff --git a/test/unit/factories/message-handler-factory.spec.ts b/test/unit/factories/message-handler-factory.spec.ts index 41124f4b..1878b703 100644 --- a/test/unit/factories/message-handler-factory.spec.ts +++ b/test/unit/factories/message-handler-factory.spec.ts @@ -2,6 +2,7 @@ import { expect } from 'chai' import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories' import { IncomingMessage, MessageType } from '../../../src/@types/messages' +import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' import { Event } from '../../../src/@types/event' import { EventMessageHandler } from '../../../src/handlers/event-message-handler' import { IWebSocketAdapter } from '../../../src/@types/adapters' @@ -74,9 +75,16 @@ describe('messageHandlerFactory', () => { expect(factory([message, adapter])).to.be.an.instanceOf(CountMessageHandler) }) + it('returns AuthMessageHandler when given an AUTH message', () => { + message = [MessageType.AUTH, event] as any + + expect(factory([message, adapter])).to.be.an.instanceOf(AuthMessageHandler) + }) + it('throws when given an invalid message', () => { message = [] as any expect(() => factory([message, adapter])).to.throw(Error, 'Unknown message type: undefined') }) }) + diff --git a/test/unit/handlers/auth-message-handler.spec.ts b/test/unit/handlers/auth-message-handler.spec.ts new file mode 100644 index 00000000..66edd38b --- /dev/null +++ b/test/unit/handlers/auth-message-handler.spec.ts @@ -0,0 +1,233 @@ +import { expect } from 'chai' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' +import chai from 'chai' + +chai.use(sinonChai) + +import { AuthMessage, MessageType } from '../../../src/@types/messages' +import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler' +import { Tag } from '../../../src/@types/base' +import { EventKinds, EventTags } from '../../../src/constants/base' +import { getPublicKey, identifyEvent, signEvent } from '../../../src/utils/event' +import { IMessageHandler } from '../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { Settings } from '../../../src/@types/settings' +import { WebSocketAdapterEvent } from '../../../src/constants/adapter' + +describe('AuthMessageHandler', () => { + let handler: IMessageHandler + let webSocket: IWebSocketAdapter + let emitStub: Sinon.SinonStub + let settingsFactory: Sinon.SinonStub + + const challenge = 'test-challenge-string-abc123' + const relayUrl = 'wss://relay.example.com' + const privkey = 'a'.repeat(64) + const pubkey = getPublicKey(privkey) + + async function createAuthEvent(overrides: { + kind?: number + challenge?: string + relayUrl?: string + created_at?: number + invalidId?: boolean + invalidSig?: boolean + } = {}): Promise { + const kind = overrides.kind ?? EventKinds.AUTH + const now = overrides.created_at ?? Math.floor(Date.now() / 1000) + const tags = [ + [EventTags.AuthRelay, overrides.relayUrl ?? relayUrl], + [EventTags.Challenge, overrides.challenge ?? challenge], + ] as Tag[] + + const identified = await identifyEvent({ + pubkey, + created_at: now, + kind, + tags, + content: '', + }) + + if (overrides.invalidId) { + identified.id = 'f'.repeat(64) + } + + const signed = overrides.invalidSig + ? { ...identified, sig: '0'.repeat(128) } + : await signEvent(privkey)(identified) + + return [ + MessageType.AUTH, + { + id: signed.id, + pubkey, + created_at: now, + kind, + tags, + content: '', + sig: signed.sig, + }, + ] as AuthMessage + } + + beforeEach(() => { + emitStub = Sinon.stub() + webSocket = { + emit: emitStub, + getClientId: Sinon.stub().returns('test-client-id'), + getClientAddress: Sinon.stub().returns('127.0.0.1'), + getSubscriptions: Sinon.stub().returns(new Map()), + getChallenge: Sinon.stub().returns(challenge), + getAuthenticatedPubkeys: Sinon.stub().returns(new Set()), + addAuthenticatedPubkey: Sinon.stub(), + } as any as IWebSocketAdapter + + settingsFactory = Sinon.stub().returns({ + info: { relay_url: relayUrl }, + } as Partial) + + handler = new AuthMessageHandler(webSocket, settingsFactory) + }) + + afterEach(() => { + Sinon.restore() + }) + + describe('handleMessage()', () => { + it('authenticates successfully with a valid AUTH event', async () => { + const message = await createAuthEvent() + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[0]).to.equal(WebSocketAdapterEvent.Message) + expect(args[1][0]).to.equal('OK') + expect(args[1][2]).to.equal(true) + }) + + it('rejects when kind is not 22242', async () => { + const message = await createAuthEvent({ kind: 1 }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('kind 22242') + }) + + it('rejects when event ID does not match', async () => { + const message = await createAuthEvent({ invalidId: true }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('event id does not match') + }) + + it('rejects when signature is invalid', async () => { + const message = await createAuthEvent({ invalidSig: true }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('signature verification failed') + }) + + it('rejects when created_at is too far in the past', async () => { + const tooOld = Math.floor(Date.now() / 1000) - 700 + const message = await createAuthEvent({ created_at: tooOld }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('created_at is too far') + }) + + it('rejects when created_at is too far in the future', async () => { + const tooNew = Math.floor(Date.now() / 1000) + 700 + const message = await createAuthEvent({ created_at: tooNew }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('created_at is too far') + }) + + it('rejects when challenge tag does not match', async () => { + const message = await createAuthEvent({ challenge: 'wrong-challenge' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('challenge does not match') + }) + + it('rejects when relay tag does not match', async () => { + const message = await createAuthEvent({ relayUrl: 'wss://wrong-relay.example.com' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('relay url does not match') + }) + + it('rejects when relay tag has invalid URL', async () => { + const message = await createAuthEvent({ relayUrl: 'not-a-url' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).not.to.have.been.called + expect(emitStub).to.have.been.calledOnce + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(false) + expect(args[1][3]).to.include('relay url does not match') + }) + + it('accepts relay URL with different path but same domain', async () => { + const message = await createAuthEvent({ relayUrl: 'wss://relay.example.com/v1' }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + const args = emitStub.firstCall.args + expect(args[1][2]).to.equal(true) + }) + + it('accepts timestamp exactly at the 10-minute boundary', async () => { + const clock = Sinon.useFakeTimers(Date.now()) + try { + const exactBoundary = Math.floor(Date.now() / 1000) - 600 + const message = await createAuthEvent({ created_at: exactBoundary }) + + await handler.handleMessage(message) + + expect((webSocket.addAuthenticatedPubkey as Sinon.SinonStub)).to.have.been.calledOnceWithExactly(pubkey) + } finally { + clock.restore() + } + }) + }) +})