Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/funky-coins-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat: NIP-42 AUTH handler and WebSocket session wiring
3 changes: 3 additions & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export type IWebSocketAdapter = EventEmitter & {
getClientId(): string
getClientAddress(): string
getSubscriptions(): Map<string, SubscriptionFilter[]>
getChallenge(): string
getAuthenticatedPubkeys(): ReadonlySet<string>
addAuthenticatedPubkey(pubkey: string): void
}

export interface ICacheAdapter {
Expand Down
24 changes: 23 additions & 1 deletion src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { randomBytes } from 'crypto'
import cluster from 'cluster'
import { EventEmitter } from 'stream'
import { IncomingMessage as IncomingHttpMessage } from 'http'
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'
Expand All @@ -32,6 +33,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private clientAddress: SocketAddress
private alive: boolean
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
private readonly challenge: string
private readonly authenticatedPubkeys: Set<string>

public constructor(
private readonly client: WebSocket,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string> {
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
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)}`)
}
Expand Down
86 changes: 86 additions & 0 deletions src/handlers/auth-message-handler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
}
}
5 changes: 5 additions & 0 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
Expand Down
93 changes: 93 additions & 0 deletions test/unit/adapters/web-socket-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ describe('WebSocketAdapter', () => {
slidingWindowRateLimiter,
settingsFactory,
)

// Reset send history so existing tests see a clean slate
client.send.resetHistory()
})

afterEach(() => {
Expand Down Expand Up @@ -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()
})
})
})
8 changes: 8 additions & 0 deletions test/unit/factories/message-handler-factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
})
})

Loading
Loading