Skip to content

Commit cc8d57d

Browse files
feat: add NIP-42 client authentication
Implement challenge-response auth per NIP-42 spec: - Send AUTH challenge on WebSocket connect - Verify kind 22242 events (ID, sig, timestamp, challenge, relay) - Track authenticated pubkeys per connection - Block kind 22242 from EVENT message pipeline
1 parent 36e5af8 commit cc8d57d

14 files changed

Lines changed: 525 additions & 4 deletions

File tree

.changeset/add-nip-42-auth.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: add NIP-42 client authentication

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/@types/messages.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ export enum MessageType {
1212
OK = 'OK',
1313
COUNT = 'COUNT',
1414
CLOSED = 'CLOSED',
15+
AUTH = 'AUTH',
1516
}
1617

17-
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage) & {
18+
export type IncomingMessage = (SubscribeMessage | IncomingEventMessage | UnsubscribeMessage | CountMessage | AuthMessage) & {
1819
[ContextMetadataKey]?: ContextMetadata
1920
}
2021

21-
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage
22+
export type OutgoingMessage = OutgoingEventMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult | CountResultMessage | ClosedMessage | AuthChallengeMessage
2223

2324
export type SubscribeMessage = {
2425
[index in Range<2, 100>]: SubscriptionFilter
@@ -89,3 +90,14 @@ export interface ClosedMessage {
8990
1: SubscriptionId
9091
2: string
9192
}
93+
94+
// NIP-42
95+
export interface AuthMessage {
96+
0: MessageType.AUTH
97+
1: Event
98+
}
99+
100+
export interface AuthChallengeMessage {
101+
0: MessageType.AUTH
102+
1: string
103+
}

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/constants/base.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export enum EventKinds {
4545
REPLACEABLE_LAST = 19999,
4646
// Ephemeral events
4747
EPHEMERAL_FIRST = 20000,
48+
// NIP-42: Client Authentication
49+
AUTH = 22242,
4850
EPHEMERAL_LAST = 29999,
4951
// Parameterized replaceable events
5052
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
@@ -72,6 +74,9 @@ export enum EventTags {
7274
Emoji = 'emoji',
7375
// NIP-12: geohash tag for location-based queries
7476
Geohash = 'g',
77+
// NIP-42: Authentication tags
78+
Challenge = 'challenge',
79+
AuthRelay = 'relay',
7580
// Marmot Protocol MIP-03: group ID for filtering kind:445 Group Events
7681
Group = 'h',
7782
}

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> {

src/schemas/message-schema.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,7 @@ export const countMessageSchema = z
5555

5656
export const closeMessageSchema = z.tuple([z.literal(MessageType.CLOSE), subscriptionSchema])
5757

58-
export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema])
58+
// NIP-42
59+
export const authMessageSchema = z.tuple([z.literal(MessageType.AUTH), eventSchema])
60+
61+
export const messageSchema = z.union([eventMessageSchema, reqMessageSchema, closeMessageSchema, countMessageSchema, authMessageSchema])

src/utils/messages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
AuthChallengeMessage,
23
ClosedMessage,
34
CountResultMessage,
45
CountResultPayload,
@@ -41,6 +42,11 @@ export const createClosedMessage = (queryId: SubscriptionId, reason: string): Cl
4142
return [MessageType.CLOSED, queryId, reason]
4243
}
4344

45+
// NIP-42
46+
export const createAuthChallengeMessage = (challenge: string): AuthChallengeMessage => {
47+
return [MessageType.AUTH, challenge]
48+
}
49+
4450
export const createSubscriptionMessage = (
4551
subscriptionId: SubscriptionId,
4652
filters: SubscriptionFilter[],

0 commit comments

Comments
 (0)