Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: NIP-42 #179

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ NIPs with a relay-specific implementation are listed here.
- [x] NIP-28: Public Chat
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [x] NIP-42: Authentication of clients to relays
- [x] NIP-111: Relay Information Document Extensions

## Requirements
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
28,
33,
40,
42,
111
],
"main": "src/index.ts",
Expand Down
2 changes: 2 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ paymentsProcessors:
ipWhitelist:
- "3.225.112.64"
- "::ffff:3.225.112.64"
authentication:
enabled: false
network:
maxPayloadSize: 524288
remoteIpHeader: x-forwarded-for
Expand Down
2 changes: 2 additions & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type IWebSocketAdapter = EventEmitter & {
getClientId(): string
getClientAddress(): string
getSubscriptions(): Map<string, SubscriptionFilter[]>
getClientAuthChallengeData(): { challenge: string, createdAt: Date } | undefined
setClientToAuthenticated(): void
}

export interface ICacheAdapter {
Expand Down
13 changes: 13 additions & 0 deletions src/@types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SubscriptionFilter, SubscriptionId } from './subscription'
import { ContextMetadataKey } from '../constants/base'

export enum MessageType {
AUTH = 'AUTH',
REQ = 'REQ',
EVENT = 'EVENT',
CLOSE = 'CLOSE',
Expand All @@ -15,6 +16,7 @@ export enum MessageType {
export type IncomingMessage = (
| SubscribeMessage
| IncomingEventMessage
| IncomingAuthMessage
| UnsubscribeMessage
) & {
[ContextMetadataKey]?: ContextMetadata
Expand All @@ -23,6 +25,7 @@ export type IncomingMessage = (

export type OutgoingMessage =
| OutgoingEventMessage
| OutgoingAuthMessage
| EndOfStoredEventsNotice
| NoticeMessage
| CommandResult
Expand Down Expand Up @@ -50,6 +53,16 @@ export interface OutgoingEventMessage {
2: Event
}

export interface OutgoingAuthMessage {
0: MessageType.AUTH
1: string
}

export interface IncomingAuthMessage {
0: MessageType.AUTH
1: Event
}

export interface UnsubscribeMessage {
0: MessageType.CLOSE
1: SubscriptionId
Expand Down
5 changes: 5 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export interface Limits {
message?: MessageLimits
}

export interface Authentication {
enabled: boolean
}

export interface Worker {
count: number
}
Expand Down Expand Up @@ -171,6 +175,7 @@ export interface Mirroring {
}

export interface Settings {
authentication: Authentication
info: Info
payments?: Payments
paymentsProcessors?: PaymentsProcessors
Expand Down
69 changes: 65 additions & 4 deletions src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import cluster from 'cluster'
import { ContextMetadataKey } from '../constants/base'
import { EventEmitter } from 'stream'
import { IncomingMessage as IncomingHttpMessage } from 'http'
import { randomBytes } from 'crypto'
import { WebSocket } from 'ws'

import { ContextMetadata, Factory } from '../@types/base'
import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { createAuthMessage, createCommandResult, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages'
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
import { IncomingMessage, OutgoingMessage } from '../@types/messages'
import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages'
import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters'
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter'
import { attemptValidation } from '../utils/validation'
import { ContextMetadataKey } from '../constants/base'
import { createLogger } from '../factories/logger-factory'
import { Event } from '../@types/event'
import { getRemoteAddress } from '../utils/http'
Expand All @@ -21,7 +22,6 @@ import { messageSchema } from '../schemas/message-schema'
import { Settings } from '../@types/settings'
import { SocketAddress } from 'net'


const debug = createLogger('web-socket-adapter')
const debugHeartbeat = debug.extend('heartbeat')

Expand All @@ -32,6 +32,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
private clientAddress: SocketAddress
private alive: boolean
private subscriptions: Map<SubscriptionId, SubscriptionFilter[]>
private authChallenge: { createdAt: Date, challenge: string } | undefined
private authenticated: boolean

public constructor(
private readonly client: WebSocket,
Expand Down Expand Up @@ -96,8 +98,28 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.subscriptions.set(subscriptionId, filters)
}

public setNewAuthChallenge(): string {
const challenge = randomBytes(16).toString('hex')
this.authChallenge = {
createdAt: new Date(),
challenge,
}

return challenge
}

public setClientToAuthenticated() {
this.authenticated = true
this.authChallenge = undefined
}

public getClientAuthChallengeData() {
return this.authChallenge
}

public onBroadcast(event: Event): void {
this.webSocketServer.emit(WebSocketServerAdapterEvent.Broadcast, event)

if (cluster.isWorker && typeof process.send === 'function') {
process.send({
eventName: WebSocketServerAdapterEvent.Broadcast,
Expand Down Expand Up @@ -157,6 +179,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
}

const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8')))
debug('recv client msg: %o', message)

const requiresAuthentication = this.isAuthenticationRequired(message)
if (requiresAuthentication) return

message[ContextMetadataKey] = {
remoteAddress: this.clientAddress,
Expand All @@ -178,6 +204,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter

await messageHandler.handleMessage(message)
} catch (error) {
console.error('mistakes were made', error)
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.error(`web-socket-adapter: abort from client ${this.clientId} (${this.getClientAddress()})`)
Expand Down Expand Up @@ -251,6 +278,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
}

private onClientClose() {
this.authenticated = false
this.alive = false
this.subscriptions.clear()

Expand All @@ -268,4 +296,37 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
this.removeAllListeners()
this.client.removeAllListeners()
}

private isAuthenticationRequired(message): boolean {
if (
!this.authenticated
&& message[0] !== MessageType.AUTH
&& message[0] !== MessageType.CLOSE
&& this.settings().authentication.enabled
) {
switch(message[0]) {
case MessageType.REQ: {
const challenge = this.setNewAuthChallenge()
this.sendMessage(createAuthMessage(challenge))
return true
}

case MessageType.EVENT: {
const challenge = this.setNewAuthChallenge()
this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized'))
this.sendMessage(createAuthMessage(challenge))
return true
}

default: {
const challenge = this.setNewAuthChallenge()
this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized'))
this.sendMessage(createAuthMessage(challenge))
return true
}
}
}

return false
}
}
3 changes: 3 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export enum EventKinds {
REPLACEABLE_LAST = 19999,
// Ephemeral events
EPHEMERAL_FIRST = 20000,
AUTH = 22242,
EPHEMERAL_LAST = 29999,
// Parameterized replaceable events
PARAMETERIZED_REPLACEABLE_FIRST = 30000,
Expand All @@ -37,6 +38,8 @@ export enum EventTags {
Delegation = 'delegation',
Deduplication = 'd',
Expiration = 'expiration',
Relay = 'relay',
Challenge = 'challenge'
}

export enum PaymentsProcessors {
Expand Down
11 changes: 11 additions & 0 deletions src/factories/auth-event-strategy-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Event } from '../@types/event'
import { Factory } from '../@types/base'
import { IEventStrategy } from '../@types/message-handlers'
import { IWebSocketAdapter } from '../@types/adapters'
import { SignedAuthEventStrategy } from '../handlers/event-strategies/auth-event-strategy'

export const signedAuthEventStrategyFactory = (
): Factory<IEventStrategy<Event, Promise<void>>, [Event, IWebSocketAdapter]> =>
([, adapter]: [Event, IWebSocketAdapter]) => {
return new SignedAuthEventStrategy(adapter)
}
11 changes: 11 additions & 0 deletions src/factories/message-handler-factory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { IEventRepository, IUserRepository } from '../@types/repositories'
import { IncomingMessage, MessageType } from '../@types/messages'
import { AuthEventMessageHandler } from '../handlers/auth-event-message-handler'
import { createSettings } from './settings-factory'
import { DelegatedEventMessageHandler } from '../handlers/delegated-event-message-handler'
import { delegatedEventStrategyFactory } from './delegated-event-strategy-factory'
import { EventMessageHandler } from '../handlers/event-message-handler'
import { eventStrategyFactory } from './event-strategy-factory'
import { isDelegatedEvent } from '../utils/event'
import { IWebSocketAdapter } from '../@types/adapters'
import { signedAuthEventStrategyFactory } from './auth-event-strategy-factory'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler'
import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler'
Expand Down Expand Up @@ -38,6 +40,15 @@ export const messageHandlerFactory = (
}
case MessageType.REQ:
return new SubscribeMessageHandler(adapter, eventRepository, createSettings)
case MessageType.AUTH: {
return new AuthEventMessageHandler(
adapter,
signedAuthEventStrategyFactory(),
userRepository,
createSettings,
slidingWindowRateLimiterFactory,
)
}
case MessageType.CLOSE:
return new UnsubscribeMessageHandler(adapter,)
default:
Expand Down
68 changes: 68 additions & 0 deletions src/handlers/auth-event-message-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createCommandResult } from '../utils/messages'
import { createLogger } from '../factories/logger-factory'
import { DelegatedEvent } from '../@types/event'
import { EventMessageHandler } from './event-message-handler'
import { IMessageHandler } from '../@types/message-handlers'
import { IncomingEventMessage } from '../@types/messages'
import { isSignedAuthEvent } from '../utils/event'
import { WebSocketAdapterEvent } from '../constants/adapter'

const debug = createLogger('delegated-event-message-handler')

export class AuthEventMessageHandler extends EventMessageHandler implements IMessageHandler {
public async handleMessage(message: IncomingEventMessage): Promise<void> {
const [, event] = message

let reason = await this.isEventValid(event)
if (reason) {
debug('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason))
return
}

if (await this.isRateLimited(event)) {
debug('event %s rejected: rate-limited')
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'rate-limited: slow down'))
return
}

reason = this.canAcceptEvent(event)
if (reason) {
debug('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason))
return
}

reason = await this.isUserAdmitted(event)
if (reason) {
debug('event %s rejected: %s', event.id, reason)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason))
return
}

const strategy = this.strategyFactory([event, this.webSocket])

if (typeof strategy?.execute !== 'function') {
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'error: event not supported'))
return
}

try {
await strategy.execute(event)
} catch (error) {
console.error('error handling message', message, error)
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'error: unable to process event'))
}
}

protected async isEventValid(event: DelegatedEvent): Promise<string | undefined> {
const reason = await super.isEventValid(event)
if (reason) {
return reason
}

if (!isSignedAuthEvent(event)) {
return 'invalid: auth verification failed'
}
}
}
33 changes: 33 additions & 0 deletions src/handlers/event-strategies/auth-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createCommandResult } from '../../utils/messages'
import { createLogger } from '../../factories/logger-factory'
import { Event } from '../../@types/event'
import { IEventStrategy } from '../../@types/message-handlers'
import { isValidSignedAuthEvent } from '../../utils/event'
import { IWebSocketAdapter } from '../../@types/adapters'
import { WebSocketAdapterEvent } from '../../constants/adapter'

const permittedChallengeResponseTimeDelayMs = (1000 * 60 * 10) // 10 min
const debug = createLogger('default-event-strategy')

export class SignedAuthEventStrategy implements IEventStrategy<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
) { }

public async execute(event: Event): Promise<void> {
debug('received signedAuth event: %o', event)
const { challenge, createdAt } = this.webSocket.getClientAuthChallengeData()
const verified = isValidSignedAuthEvent(event, challenge)

const timeIsWithinBounds = (createdAt.getTime() + permittedChallengeResponseTimeDelayMs) > Date.now()

debug('banana', timeIsWithinBounds, verified)
if (verified && timeIsWithinBounds) {
this.webSocket.setClientToAuthenticated()
this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, 'authentication: succeeded'))
return
}

this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'authentication: failed'))
}
}
Loading