Skip to content

Commit 44034c7

Browse files
feat: wire NIP-42 AUTH handler into WebSocket adapter
1 parent 54139ed commit 44034c7

9 files changed

Lines changed: 459 additions & 1 deletion

File tree

.changeset/funky-coins-know.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat: NIP-42 AUTH handler and WebSocket session wiring

src/@types/adapters.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export type IWebSocketAdapter = EventEmitter & {
1515
getClientId(): string
1616
getClientAddress(): string
1717
getSubscriptions(): Map<string, SubscriptionFilter[]>
18+
getChallenge(): string
19+
getAuthenticatedPubkeys(): ReadonlySet<string>
20+
addAuthenticatedPubkey(pubkey: string): void
1821
}
1922

2023
export interface ICacheAdapter {

src/adapters/web-socket-adapter.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
import { randomBytes } from 'crypto'
12
import cluster from 'cluster'
23
import { EventEmitter } from 'stream'
34
import { IncomingMessage as IncomingHttpMessage } from 'http'
45
import { WebSocket } from 'ws'
56
import { ZodError } from 'zod'
67

78
import { ContextMetadata, Factory } from '../@types/base'
8-
import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
9+
import { createAuthChallengeMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
910
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
1011
import { IncomingMessage, OutgoingMessage } from '../@types/messages'
1112
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
@@ -32,6 +33,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
3233
private clientAddress: SocketAddress
3334
private alive: boolean
3435
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
36+
private readonly challenge: string
37+
private readonly authenticatedPubkeys: Set<string>
3538

3639
public constructor(
3740
private readonly client: WebSocket,
@@ -79,6 +82,11 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
7982
.on(WebSocketAdapterEvent.Message, this.sendMessage.bind(this))
8083

8184
logger('client %s connected from %s', this.clientId, this.clientAddress.address)
85+
86+
// NIP-42
87+
this.challenge = randomBytes(32).toString('base64url')
88+
this.authenticatedPubkeys = new Set()
89+
this.sendMessage(createAuthChallengeMessage(this.challenge))
8290
}
8391

8492
public getClientId(): string {
@@ -141,6 +149,19 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
141149
return new Map(this.subscriptions)
142150
}
143151

152+
// NIP-42
153+
public getChallenge(): string {
154+
return this.challenge
155+
}
156+
157+
public getAuthenticatedPubkeys(): ReadonlySet<string> {
158+
return new Set(this.authenticatedPubkeys)
159+
}
160+
161+
public addAuthenticatedPubkey(pubkey: string): void {
162+
this.authenticatedPubkeys.add(pubkey)
163+
}
164+
144165
private async onClientMessage(raw: Buffer) {
145166
this.alive = true
146167
let abortable = false
@@ -241,6 +262,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
241262
private onClientClose() {
242263
this.alive = false
243264
this.subscriptions.clear()
265+
this.authenticatedPubkeys.clear()
244266

245267
const handlers = abortableMessageHandlers.get(this.client)
246268
if (Array.isArray(handlers) && handlers.length) {

src/factories/message-handler-factory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ICacheAdapter, IWebSocketAdapter } from '../@types/adapters'
22
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories'
33
import { IncomingMessage, MessageType } from '../@types/messages'
44
import { createSettings } from './settings-factory'
5+
import { AuthMessageHandler } from '../handlers/auth-message-handler'
56
import { CountMessageHandler } from '../handlers/count-message-handler'
67
import { EventMessageHandler } from '../handlers/event-message-handler'
78
import { eventStrategyFactory } from './event-strategy-factory'
@@ -45,6 +46,8 @@ export const messageHandlerFactory =
4546
return new UnsubscribeMessageHandler(adapter)
4647
case MessageType.COUNT:
4748
return new CountMessageHandler(adapter, eventRepository, createSettings)
49+
case MessageType.AUTH:
50+
return new AuthMessageHandler(adapter, createSettings)
4851
default:
4952
throw new Error(`Unknown message type: ${String(message[0]).substring(0, 64)}`)
5053
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { EventKinds, EventTags } from '../constants/base'
2+
import { isEventIdValid, isEventSignatureValid } from '../utils/event'
3+
import { AuthMessage } from '../@types/messages'
4+
import { createCommandResult } from '../utils/messages'
5+
import { createLogger } from '../factories/logger-factory'
6+
import { IMessageHandler } from '../@types/message-handlers'
7+
import { IWebSocketAdapter } from '../@types/adapters'
8+
import { Settings } from '../@types/settings'
9+
import { WebSocketAdapterEvent } from '../constants/adapter'
10+
11+
const logger = createLogger('auth-message-handler')
12+
13+
const AUTH_EVENT_KIND = EventKinds.AUTH // 22242
14+
const MAX_TIMESTAMP_DELTA_SECONDS = 600 // 10 minutes
15+
16+
export class AuthMessageHandler implements IMessageHandler {
17+
public constructor(
18+
private readonly webSocket: IWebSocketAdapter,
19+
private readonly settings: () => Settings,
20+
) {}
21+
22+
public async handleMessage(message: AuthMessage): Promise<void> {
23+
const event = message[1]
24+
25+
if (event.kind !== AUTH_EVENT_KIND) {
26+
this.sendResult(event.id, false, 'invalid: auth event must be kind 22242')
27+
return
28+
}
29+
30+
if (!(await isEventIdValid(event))) {
31+
this.sendResult(event.id, false, 'invalid: event id does not match')
32+
return
33+
}
34+
35+
if (!(await isEventSignatureValid(event))) {
36+
this.sendResult(event.id, false, 'invalid: event signature verification failed')
37+
return
38+
}
39+
40+
const now = Math.floor(Date.now() / 1000)
41+
const delta = Math.abs(now - event.created_at)
42+
if (delta > MAX_TIMESTAMP_DELTA_SECONDS) {
43+
this.sendResult(event.id, false, 'invalid: created_at is too far from the current time')
44+
return
45+
}
46+
47+
const challengeTag = event.tags.find(
48+
(tag) => tag.length >= 2 && tag[0] === EventTags.Challenge,
49+
)
50+
if (!challengeTag || challengeTag[1] !== this.webSocket.getChallenge()) {
51+
this.sendResult(event.id, false, 'invalid: challenge does not match')
52+
return
53+
}
54+
55+
const relayTag = event.tags.find(
56+
(tag) => tag.length >= 2 && tag[0] === EventTags.AuthRelay,
57+
)
58+
const relayUrl = this.settings().info.relay_url
59+
if (!relayTag || !this.isRelayUrlMatch(relayTag[1], relayUrl)) {
60+
this.sendResult(event.id, false, 'invalid: relay url does not match')
61+
return
62+
}
63+
64+
logger('client %s authenticated as %s', this.webSocket.getClientId(), event.pubkey)
65+
this.webSocket.addAuthenticatedPubkey(event.pubkey)
66+
this.sendResult(event.id, true, '')
67+
}
68+
69+
private sendResult(eventId: string, success: boolean, message: string): void {
70+
this.webSocket.emit(
71+
WebSocketAdapterEvent.Message,
72+
createCommandResult(eventId, success, message),
73+
)
74+
}
75+
76+
// NIP-42 says domain-match is sufficient for relay URL comparison
77+
private isRelayUrlMatch(clientRelay: string, serverRelay: string): boolean {
78+
try {
79+
const clientHost = new URL(clientRelay).hostname.toLowerCase()
80+
const serverHost = new URL(serverRelay).hostname.toLowerCase()
81+
return clientHost === serverHost
82+
} catch {
83+
return false
84+
}
85+
}
86+
}

src/handlers/event-message-handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,11 @@ export class EventMessageHandler implements IMessageHandler {
244244
if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event) || isWelcomeRumorEvent(event)) {
245245
return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap`
246246
}
247+
248+
// NIP-42: auth events must use the AUTH message type
249+
if (event.kind === EventKinds.AUTH) {
250+
return 'invalid: auth events must be sent using the AUTH message type'
251+
}
247252
}
248253

249254
protected async isBlockedByRequestToVanish(event: Event): Promise<string | undefined> {

test/unit/adapters/web-socket-adapter.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ describe('WebSocketAdapter', () => {
7777
slidingWindowRateLimiter,
7878
settingsFactory,
7979
)
80+
81+
// Reset send history so existing tests see a clean slate
82+
client.send.resetHistory()
8083
})
8184

8285
afterEach(() => {
@@ -603,4 +606,94 @@ describe('WebSocketAdapter', () => {
603606
ipv6Adapter.removeAllListeners()
604607
})
605608
})
609+
610+
describe('NIP-42 authentication', () => {
611+
it('sends AUTH challenge message on construction', () => {
612+
const freshClient = {
613+
on: sandbox.stub().returnsThis(),
614+
send: sandbox.stub(),
615+
close: sandbox.stub(),
616+
ping: sandbox.stub(),
617+
pong: sandbox.stub(),
618+
readyState: WebSocket.OPEN,
619+
removeAllListeners: sandbox.stub(),
620+
}
621+
const freshAdapter = new WebSocketAdapter(
622+
freshClient as any,
623+
request,
624+
webSocketServer as any,
625+
createMessageHandler,
626+
slidingWindowRateLimiter,
627+
settingsFactory,
628+
)
629+
630+
expect(freshClient.send).to.have.been.calledOnce
631+
const sent = JSON.parse(freshClient.send.firstCall.args[0])
632+
expect(sent[0]).to.equal('AUTH')
633+
expect(sent[1]).to.be.a('string')
634+
expect(sent[1].length).to.be.greaterThan(0)
635+
freshAdapter.removeAllListeners()
636+
})
637+
638+
it('getChallenge returns a non-empty string', () => {
639+
const challenge = adapter.getChallenge()
640+
expect(challenge).to.be.a('string')
641+
expect(challenge.length).to.be.greaterThan(0)
642+
})
643+
644+
it('getChallenge returns consistent value for the same adapter', () => {
645+
const c1 = adapter.getChallenge()
646+
const c2 = adapter.getChallenge()
647+
expect(c1).to.equal(c2)
648+
})
649+
650+
it('getAuthenticatedPubkeys returns empty set initially', () => {
651+
const pubkeys = adapter.getAuthenticatedPubkeys()
652+
expect(pubkeys.size).to.equal(0)
653+
})
654+
655+
it('addAuthenticatedPubkey adds a pubkey', () => {
656+
const pubkey = 'a'.repeat(64)
657+
adapter.addAuthenticatedPubkey(pubkey)
658+
659+
const pubkeys = adapter.getAuthenticatedPubkeys()
660+
expect(pubkeys.size).to.equal(1)
661+
expect(pubkeys.has(pubkey)).to.be.true
662+
})
663+
664+
it('addAuthenticatedPubkey supports multiple pubkeys', () => {
665+
const pk1 = 'a'.repeat(64)
666+
const pk2 = 'b'.repeat(64)
667+
adapter.addAuthenticatedPubkey(pk1)
668+
adapter.addAuthenticatedPubkey(pk2)
669+
670+
const pubkeys = adapter.getAuthenticatedPubkeys()
671+
expect(pubkeys.size).to.equal(2)
672+
expect(pubkeys.has(pk1)).to.be.true
673+
expect(pubkeys.has(pk2)).to.be.true
674+
})
675+
676+
it('addAuthenticatedPubkey deduplicates same pubkey', () => {
677+
const pubkey = 'a'.repeat(64)
678+
adapter.addAuthenticatedPubkey(pubkey)
679+
adapter.addAuthenticatedPubkey(pubkey)
680+
681+
const pubkeys = adapter.getAuthenticatedPubkeys()
682+
expect(pubkeys.size).to.equal(1)
683+
})
684+
685+
it('generates different challenges for different adapters', () => {
686+
const adapter2 = new WebSocketAdapter(
687+
client,
688+
request,
689+
webSocketServer as any,
690+
createMessageHandler,
691+
slidingWindowRateLimiter,
692+
settingsFactory,
693+
)
694+
695+
expect(adapter.getChallenge()).not.to.equal(adapter2.getChallenge())
696+
adapter2.removeAllListeners()
697+
})
698+
})
606699
})

test/unit/factories/message-handler-factory.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect } from 'chai'
22

33
import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../../../src/@types/repositories'
44
import { IncomingMessage, MessageType } from '../../../src/@types/messages'
5+
import { AuthMessageHandler } from '../../../src/handlers/auth-message-handler'
56
import { Event } from '../../../src/@types/event'
67
import { EventMessageHandler } from '../../../src/handlers/event-message-handler'
78
import { IWebSocketAdapter } from '../../../src/@types/adapters'
@@ -74,9 +75,16 @@ describe('messageHandlerFactory', () => {
7475
expect(factory([message, adapter])).to.be.an.instanceOf(CountMessageHandler)
7576
})
7677

78+
it('returns AuthMessageHandler when given an AUTH message', () => {
79+
message = [MessageType.AUTH, event] as any
80+
81+
expect(factory([message, adapter])).to.be.an.instanceOf(AuthMessageHandler)
82+
})
83+
7784
it('throws when given an invalid message', () => {
7885
message = [] as any
7986

8087
expect(() => factory([message, adapter])).to.throw(Error, 'Unknown message type: undefined')
8188
})
8289
})
90+

0 commit comments

Comments
 (0)