diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 0e491c3..3767a17 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -16,6 +16,8 @@ export type IWebSocketAdapter = EventEmitter & { getClientId(): string getClientAddress(): string getSubscriptions(): Map + getClientChallenge?(): string, + setClientToAuthenticated?() } export interface ICacheAdapter { diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 63f24b6..274b6fa 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -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', diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 4e584f6..c3f5268 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,6 +1,7 @@ import cluster from 'cluster' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' +import { randomBytes } from 'crypto' import { WebSocket } from 'ws' import { ContextMetadata, Factory } from '../@types/base' @@ -32,6 +33,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map + private authChallenge: { createdAt: Date, challenge: Buffer } + private authenticated: boolean public constructor( private readonly client: WebSocket, @@ -96,8 +99,24 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.subscriptions.set(subscriptionId, filters) } + public setNewAuthChallenge() { + this.authChallenge = { + createdAt: new Date(), + challenge: randomBytes(32), + } + } + + public setClientToAuthenticated() { + this.authenticated = true + } + + public getClientAuthChallenge() { + 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, diff --git a/src/constants/base.ts b/src/constants/base.ts index b5a29a0..823f2f9 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -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, @@ -37,6 +38,8 @@ export enum EventTags { Delegation = 'delegation', Deduplication = 'd', Expiration = 'expiration', + Relay = 'relay', + Challenge = 'challenge' } export enum PaymentsProcessors { diff --git a/src/factories/auth-event-strategy-factory.ts b/src/factories/auth-event-strategy-factory.ts new file mode 100644 index 0000000..4b21a30 --- /dev/null +++ b/src/factories/auth-event-strategy-factory.ts @@ -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>, [Event, IWebSocketAdapter]> => + ([, adapter]: [Event, IWebSocketAdapter]) => { + return new SignedAuthEventStrategy(adapter) + } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index a3123ce..014b415 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,12 +1,14 @@ import { IEventRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' +import { isDelegatedEvent, isSignedAuthEvent } from '../utils/event' +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' @@ -28,6 +30,16 @@ export const messageHandlerFactory = ( ) } + if (isSignedAuthEvent(message[1])) { + return new AuthEventMessageHandler( + adapter, + signedAuthEventStrategyFactory(), + userRepository, + createSettings, + slidingWindowRateLimiterFactory, + ) + } + return new EventMessageHandler( adapter, eventStrategyFactory(eventRepository), diff --git a/src/handlers/auth-event-message-handler.ts b/src/handlers/auth-event-message-handler.ts new file mode 100644 index 0000000..7f29d44 --- /dev/null +++ b/src/handlers/auth-event-message-handler.ts @@ -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 { + 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 { + const reason = await super.isEventValid(event) + if (reason) { + return reason + } + + if (!await isSignedAuthEvent(event)) { + return 'invalid: auth verification failed' + } + } +} diff --git a/src/handlers/event-strategies/auth-event-strategy.ts b/src/handlers/event-strategies/auth-event-strategy.ts new file mode 100644 index 0000000..fc6552b --- /dev/null +++ b/src/handlers/event-strategies/auth-event-strategy.ts @@ -0,0 +1,33 @@ +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' + +const debug = createLogger('default-event-strategy') + +export class SignedAuthEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + ) { } + + // TODO this is how we send out events, we need to do this + // in the message handler (verify if this is true) + public async execute(event: Event): Promise { + debug('received signedAuth event: %o', event) + const clientChallenge = this.webSocket.getClientChallenge() + const verified = await isValidSignedAuthEvent(event, clientChallenge) + + if (verified) { + this.webSocket.setClientToAuthenticated() + } + + // NOTE: we can add a message here if auth fails + // this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, 'auth error')) + + // NOTE: we can add a message here if auth succeeds + // if (verified) { + // this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, 'successful auth')) + // } + } +} diff --git a/src/utils/event.ts b/src/utils/event.ts index 7edd026..52ac054 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -10,6 +10,7 @@ import { EventKindsRange } from '../@types/settings' import { fromBuffer } from './transform' import { getLeadingZeroBits } from './proof-of-work' import { isGenericTagQuery } from './filter' +import { MessageType } from '../@types/messages' import { RuneLike } from './runes/rune-like' import { SubscriptionFilter } from '../@types/subscription' import { WebSocketServerAdapterEvent } from '../constants/adapter' @@ -114,6 +115,32 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve return true } +export const isSignedAuthEvent = (event: Event): boolean => { + if (!event.content || !event.tags) return false + + const evenKindIsValid = event.kind !== EventKinds.AUTH + const hasAuthDeclaration = event.content[0] === MessageType.AUTH && event.content[1] + const relay = event.tags.some((tag) => tag.length === 2 && tag[0] === EventTags.Relay) + const challenge = event.tags.some((tag) => tag.length === 2 && tag[0] === EventTags.Challenge) + + return Boolean(evenKindIsValid && hasAuthDeclaration && relay && challenge) +} + +export const isValidSignedAuthEvent = async (event: Event, challenge: string): Promise => { + const signedAuthEvent = isSignedAuthEvent(event) + + if (signedAuthEvent) { + const sig = event.tags.find(tag => tag.length >= 2 && tag[0] === EventTags.Challenge) + + const isValidSignedChallenge = secp256k1.schnorr.verify(sig[1], challenge, event.pubkey) + if (isValidSignedChallenge) { + return true + } + } + + return false +} + export const isDelegatedEvent = (event: Event): boolean => { return event.tags.some((tag) => tag.length === 4 && tag[0] === EventTags.Delegation) }