From dc0e8f31397839cef50c94e36e4bc045f31bb49a Mon Sep 17 00:00:00 2001 From: Anton Livaja Date: Mon, 6 Feb 2023 13:58:25 -0500 Subject: [PATCH 01/10] feat: inital commit for nip-42 --- src/@types/adapters.ts | 2 + src/@types/messages.ts | 1 + src/adapters/web-socket-adapter.ts | 19 ++++++ src/constants/base.ts | 3 + src/factories/auth-event-strategy-factory.ts | 11 +++ src/factories/message-handler-factory.ts | 14 +++- src/handlers/auth-event-message-handler.ts | 68 +++++++++++++++++++ .../event-strategies/auth-event-strategy.ts | 33 +++++++++ src/utils/event.ts | 27 ++++++++ 9 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/factories/auth-event-strategy-factory.ts create mode 100644 src/handlers/auth-event-message-handler.ts create mode 100644 src/handlers/event-strategies/auth-event-strategy.ts diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 0e491c3a..3767a178 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 63f24b62..274b6faf 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 4e584f6b..c3f52682 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 b5a29a0c..823f2f9e 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 00000000..4b21a309 --- /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 a3123ce2..014b415f 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 00000000..7f29d440 --- /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 00000000..fc6552bb --- /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 7edd0263..52ac0542 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) } From 5610a9fde11b5a112d9cffeb8247713153fc1bda Mon Sep 17 00:00:00 2001 From: Anton Livaja Date: Tue, 7 Feb 2023 11:28:36 -0500 Subject: [PATCH 02/10] feat: refactor auth challenge check --- src/@types/adapters.ts | 2 +- src/adapters/web-socket-adapter.ts | 6 ++--- .../event-strategies/auth-event-strategy.ts | 23 +++++++++---------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 3767a178..bbd9b02a 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -16,7 +16,7 @@ export type IWebSocketAdapter = EventEmitter & { getClientId(): string getClientAddress(): string getSubscriptions(): Map - getClientChallenge?(): string, + getClientAuthChallengeData?(): { challenge: string, createdAt: Date }, setClientToAuthenticated?() } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index c3f52682..7f359bdf 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -33,7 +33,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map - private authChallenge: { createdAt: Date, challenge: Buffer } + private authChallenge: { createdAt: Date, challenge: string } private authenticated: boolean public constructor( @@ -102,7 +102,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter public setNewAuthChallenge() { this.authChallenge = { createdAt: new Date(), - challenge: randomBytes(32), + challenge: randomBytes(32).toString('hex'), } } @@ -110,7 +110,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.authenticated = true } - public getClientAuthChallenge() { + public getClientAuthChallengeData() { return this.authChallenge } diff --git a/src/handlers/event-strategies/auth-event-strategy.ts b/src/handlers/event-strategies/auth-event-strategy.ts index fc6552bb..cb208224 100644 --- a/src/handlers/event-strategies/auth-event-strategy.ts +++ b/src/handlers/event-strategies/auth-event-strategy.ts @@ -1,8 +1,10 @@ +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 debug = createLogger('default-event-strategy') @@ -11,23 +13,20 @@ export class SignedAuthEventStrategy implements IEventStrategy { debug('received signedAuth event: %o', event) - const clientChallenge = this.webSocket.getClientChallenge() - const verified = await isValidSignedAuthEvent(event, clientChallenge) + const { challenge, createdAt } = this.webSocket.getClientAuthChallengeData() + const verified = await isValidSignedAuthEvent(event, challenge) - if (verified) { + const permittedChallengeResponseTimeDelayMs = (1000 * 60 * 10) // 10 min + const timeIsWithinBounds = (createdAt.getTime() + permittedChallengeResponseTimeDelayMs) < Date.now() + + if (verified && timeIsWithinBounds) { this.webSocket.setClientToAuthenticated() + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, 'authentication: succeeded')) + return } - // 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')) - // } + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, 'authentication: failed')) } } From 320d67389b593366817d5ff7fe88128e9dc97044 Mon Sep 17 00:00:00 2001 From: Anton Livaja Date: Tue, 7 Feb 2023 19:38:37 -0500 Subject: [PATCH 03/10] feat: refactor and add integration tests --- src/@types/adapters.ts | 4 +- src/@types/messages.ts | 6 + src/@types/settings.ts | 5 + src/adapters/web-socket-adapter.ts | 29 ++++- .../event-strategies/auth-event-strategy.ts | 2 +- src/schemas/message-schema.ts | 10 ++ src/utils/event.ts | 33 ++++-- src/utils/messages.ts | 5 + test/integration/features/helpers.ts | 14 ++- .../features/nip-42/nip-42.feature | 6 + .../features/nip-42/nip-42.feature.ts | 28 +++++ test/unit/utils/event.spec.ts | 108 +++++++++++++++++- 12 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 test/integration/features/nip-42/nip-42.feature create mode 100644 test/integration/features/nip-42/nip-42.feature.ts diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index bbd9b02a..c49083e4 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -16,8 +16,8 @@ export type IWebSocketAdapter = EventEmitter & { getClientId(): string getClientAddress(): string getSubscriptions(): Map - getClientAuthChallengeData?(): { challenge: string, createdAt: Date }, - setClientToAuthenticated?() + getClientAuthChallengeData(): { challenge: string, createdAt: Date } | undefined + setClientToAuthenticated(): void } export interface ICacheAdapter { diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 274b6faf..534b78e7 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -24,6 +24,7 @@ export type IncomingMessage = ( export type OutgoingMessage = | OutgoingEventMessage + | OutgoingAuthMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult @@ -51,6 +52,11 @@ export interface OutgoingEventMessage { 2: Event } +export interface OutgoingAuthMessage { + 0: MessageType.AUTH + 1: Event +} + export interface UnsubscribeMessage { 0: MessageType.CLOSE 1: SubscriptionId diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 818e5961..b2349972 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -116,6 +116,10 @@ export interface Limits { message?: MessageLimits } +export interface Authentication { + enabled: boolean +} + export interface Worker { count: number } @@ -171,6 +175,7 @@ export interface Mirroring { } export interface Settings { + authentication: Authentication info: Info payments?: Payments paymentsProcessors?: PaymentsProcessors diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 7f359bdf..cc360562 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,18 +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 { createAuthEventMessage, 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' @@ -33,7 +33,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map - private authChallenge: { createdAt: Date, challenge: string } + private authChallenge: { createdAt: Date, challenge: string } | undefined private authenticated: boolean public constructor( @@ -100,14 +100,18 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } public setNewAuthChallenge() { + const challenge = randomBytes(16).toString('hex') this.authChallenge = { createdAt: new Date(), - challenge: randomBytes(32).toString('hex'), + challenge, } + + return challenge } public setClientToAuthenticated() { this.authenticated = true + this.authChallenge = undefined } public getClientAuthChallengeData() { @@ -177,6 +181,20 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8'))) + if ( + !this.authenticated + && this.settings().authentication.enabled + && message[0] !== MessageType.AUTH + ) { + const challenge = this.setNewAuthChallenge() + this.webSocketServer.emit( + WebSocketServerAdapterEvent.Broadcast, + createAuthEventMessage(challenge) + ) + + return + } + message[ContextMetadataKey] = { remoteAddress: this.clientAddress, } as ContextMetadata @@ -270,6 +288,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } private onClientClose() { + this.authenticated = false this.alive = false this.subscriptions.clear() diff --git a/src/handlers/event-strategies/auth-event-strategy.ts b/src/handlers/event-strategies/auth-event-strategy.ts index cb208224..14d7a7f4 100644 --- a/src/handlers/event-strategies/auth-event-strategy.ts +++ b/src/handlers/event-strategies/auth-event-strategy.ts @@ -6,6 +6,7 @@ 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> { @@ -18,7 +19,6 @@ export class SignedAuthEventStrategy implements IEventStrategy (event: Eve } export const isSignedAuthEvent = (event: Event): boolean => { - if (!event.content || !event.tags) return false + const evenKindIsValid = event.kind === EventKinds.AUTH + if (!evenKindIsValid) return false + + let relay + let challenge + for (let i = 0; i < event.tags.length; i++) { + const tag = event.tags[i] + if (tag.length < 2) { + continue + } - 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) + if (tag[0] === EventTags.Challenge) { + if (relay) return true + challenge = true + } - return Boolean(evenKindIsValid && hasAuthDeclaration && relay && challenge) + if (tag[0] === EventTags.Relay) { + if (challenge) return true + relay = true + } + } + + return false } export const isValidSignedAuthEvent = async (event: Event, challenge: string): Promise => { @@ -132,10 +146,7 @@ export const isValidSignedAuthEvent = async (event: Event, challenge: string): P 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 secp256k1.schnorr.verify(sig[1], challenge, event.pubkey) } return false diff --git a/src/utils/messages.ts b/src/utils/messages.ts index a0971e26..a67a235b 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -34,6 +34,11 @@ export const createCommandResult = (eventId: EventId, successful: boolean, messa return [MessageType.OK, eventId, successful, message] } +// NIP-42 +export const createAuthEventMessage = (challenge) => { + return [MessageType.AUTH, challenge] +} + export const createSubscriptionMessage = ( subscriptionId: SubscriptionId, filters: SubscriptionFilter[] diff --git a/test/integration/features/helpers.ts b/test/integration/features/helpers.ts index 26a2e379..744c5363 100644 --- a/test/integration/features/helpers.ts +++ b/test/integration/features/helpers.ts @@ -3,7 +3,7 @@ import { createHash, createHmac, Hash } from 'crypto' import { Observable } from 'rxjs' import WebSocket from 'ws' -import { CommandResult, MessageType, OutgoingMessage } from '../../../src/@types/messages' +import { CommandResult, MessageType, OutgoingAuthMessage, OutgoingMessage } from '../../../src/@types/messages' import { Event } from '../../../src/@types/event' import { serializeEvent } from '../../../src/utils/event' import { streams } from './shared' @@ -195,6 +195,18 @@ export async function waitForNotice(ws: WebSocket): Promise { }) } +export async function waitForAuth(ws: WebSocket): Promise { + return new Promise((resolve) => { + const observable = streams.get(ws) as Observable + + observable.subscribe((message: OutgoingMessage) => { + if (message[0] === MessageType.AUTH) { + resolve(message) + } + }) + }) +} + export async function waitForCommand(ws: WebSocket): Promise { return new Promise((resolve) => { const observable = streams.get(ws) as Observable diff --git a/test/integration/features/nip-42/nip-42.feature b/test/integration/features/nip-42/nip-42.feature new file mode 100644 index 00000000..0955cc1c --- /dev/null +++ b/test/integration/features/nip-42/nip-42.feature @@ -0,0 +1,6 @@ +Feature: NIP-42 + Scenario: Alice gets an event by ID + Given someone called Alice + And the relay requires the client to authenticate + When Alice sends a text_note event with content "hello nostr" + Then Alice receives an authentication challenge diff --git a/test/integration/features/nip-42/nip-42.feature.ts b/test/integration/features/nip-42/nip-42.feature.ts new file mode 100644 index 00000000..89f18415 --- /dev/null +++ b/test/integration/features/nip-42/nip-42.feature.ts @@ -0,0 +1,28 @@ +import { + Given, + Then, + World, +} from '@cucumber/cucumber' +import chai from 'chai' +import { EventKinds } from '../../../../src/constants/base' +import { SettingsStatic } from '../../../../src/utils/settings' +import sinonChai from 'sinon-chai' +import { waitForAuth } from '../helpers' +import { WebSocket } from 'ws' + +chai.use(sinonChai) +const { expect } = chai + +Given(/the relay requires the client to authenticate/, async function (this: World>) { + const settings = SettingsStatic.createSettings() + settings.authentication.enabled = true +}) + +Then(/(\w+) receives an authentication challenge/, async function (name: string) { + const ws = this.parameters.clients[name] as WebSocket + + const outgoingAuthMessage = await waitForAuth(ws) + const event = outgoingAuthMessage[1] + expect(event.kind).to.equal(EventKinds.AUTH) +}) + diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index 0009decb..17d66e62 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -1,6 +1,7 @@ import { expect } from 'chai' import { CanonicalEvent, Event } from '../../../src/@types/event' +import { EventKinds, EventTags } from '../../../src/constants/base' import { getEventExpiration, isDelegatedEvent, @@ -13,9 +14,10 @@ import { isExpiredEvent, isParameterizedReplaceableEvent, isReplaceableEvent, + isSignedAuthEvent, + isValidSignedAuthEvent, serializeEvent, } from '../../../src/utils/event' -import { EventKinds } from '../../../src/constants/base' describe('NIP-01', () => { describe('serializeEvent', () => { @@ -564,4 +566,108 @@ describe('NIP-40', () => { expect(isExpiredEvent(event)).to.equal(true) }) }) + + describe('isSignedAuthEvent', () => { + it('returns true if event is valid client auth event', () => { + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, 'signedChallenge'], + ] + + expect(isSignedAuthEvent(event)).to.equal(true) + }) + + it('returns false if relay tag is missing', () => { + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Challenge, 'signedChallenge'], + ] + + expect(isSignedAuthEvent(event)).to.equal(false) + }) + + it('returns false if chaellenge tag is missing', () => { + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + ] + + expect(isSignedAuthEvent(event)).to.equal(false) + }) + + it('returns false if event kind is not AUTH', () => { + event.kind = EventKinds.DELETE + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, 'signedChallenge'], + ] + + expect(isSignedAuthEvent(event)).to.equal(false) + }) + }) + + describe('isValidSignedAuthEvent', async () => { + it('returns true if event is valid client auth event', async () => { + const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' + const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' + + event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, signedHexChallenge], + ] + + expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(true) + }) + }) + + describe('isValidSignedAuthEvent', async () => { + it('returns false if challenge is different', async () => { + const challengeHex = '6468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' + const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' + + event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, signedHexChallenge], + ] + + expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false) + }) + }) + + describe('isValidSignedAuthEvent', async () => { + it('returns false if signed challenge is incorrect', async () => { + const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' + const signedHexChallenge = '0161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' + + event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, signedHexChallenge], + ] + + expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false) + }) + }) + + describe('isValidSignedAuthEvent', async () => { + it('returns true if event is valid client auth event', async () => { + const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' + const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' + + event.pubkey = 'a9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, signedHexChallenge], + ] + + expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false) + }) + }) }) \ No newline at end of file From ecfc8b73920e9b4e1121e7f1ae49712ab910ea67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Arturo=20Cabral=20Mej=C3=ADa?= Date: Tue, 7 Feb 2023 21:50:48 -0500 Subject: [PATCH 04/10] chore: remove await --- src/handlers/auth-event-message-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/auth-event-message-handler.ts b/src/handlers/auth-event-message-handler.ts index 7f29d440..0838d7e6 100644 --- a/src/handlers/auth-event-message-handler.ts +++ b/src/handlers/auth-event-message-handler.ts @@ -61,7 +61,7 @@ export class AuthEventMessageHandler extends EventMessageHandler implements IMes return reason } - if (!await isSignedAuthEvent(event)) { + if (!isSignedAuthEvent(event)) { return 'invalid: auth verification failed' } } From 24f888594f62b69eff4e8fd371ea5542d9d38f35 Mon Sep 17 00:00:00 2001 From: antonleviathan Date: Thu, 9 Feb 2023 22:30:46 -0500 Subject: [PATCH 05/10] feat: refactor server web socket adapter --- src/adapters/web-socket-adapter.ts | 49 ++++++++++++++----- .../features/nip-01/nip-01.feature.ts | 7 +++ .../features/nip-42/nip-42.feature.ts | 4 +- test/integration/features/shared.ts | 1 + 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index cc360562..8c093268 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -6,7 +6,7 @@ import { randomBytes } from 'crypto' import { WebSocket } from 'ws' import { ContextMetadata, Factory } from '../@types/base' -import { createAuthEventMessage, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' +import { createAuthEventMessage, createCommandResult, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' import { IAbortable, IMessageHandler } from '../@types/message-handlers' import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' @@ -181,18 +181,43 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8'))) - if ( - !this.authenticated - && this.settings().authentication.enabled - && message[0] !== MessageType.AUTH - ) { - const challenge = this.setNewAuthChallenge() - this.webSocketServer.emit( - WebSocketServerAdapterEvent.Broadcast, - createAuthEventMessage(challenge) - ) + if (!this.authenticated && this.settings().authentication.enabled) { + switch(message[0]) { + case MessageType.REQ: { + // Need to emit event? + const challenge = this.setNewAuthChallenge() - return + this.webSocketServer.emit( + WebSocketServerAdapterEvent.Broadcast, + createAuthEventMessage(challenge) + ) + + return + } + + case MessageType.EVENT: { + // Need to emit event? + + const challenge = this.setNewAuthChallenge() + + this.webSocketServer.emit( + WebSocketServerAdapterEvent.Broadcast, + createCommandResult(message[1].id, false, 'rejected: unauthorized') + ) + + this.webSocketServer.emit( + WebSocketServerAdapterEvent.Broadcast, + createAuthEventMessage(challenge) + ) + + return + } + + default: { + this.sendMessage(createNoticeMessage('invalid: asdcf')) + return + } + } } message[ContextMetadataKey] = { diff --git a/test/integration/features/nip-01/nip-01.feature.ts b/test/integration/features/nip-01/nip-01.feature.ts index e6ba2063..0b3f14cd 100644 --- a/test/integration/features/nip-01/nip-01.feature.ts +++ b/test/integration/features/nip-01/nip-01.feature.ts @@ -1,4 +1,5 @@ import { + Before, Then, When, World, @@ -19,10 +20,16 @@ import { } from '../helpers' import { Event } from '../../../../src/@types/event' import { isDraft } from '../shared' +import { SettingsStatic } from '../../../../src/utils/settings' chai.use(sinonChai) const { expect } = chai +Before(function () { + const settings = SettingsStatic.createSettings() + settings.authentication.enabled = false +}) + When(/(\w+) subscribes to last event from (\w+)$/, async function(this: World>, from: string, to: string) { const ws = this.parameters.clients[from] as WebSocket const event = this.parameters.events[to].pop() diff --git a/test/integration/features/nip-42/nip-42.feature.ts b/test/integration/features/nip-42/nip-42.feature.ts index 89f18415..7bbab8fb 100644 --- a/test/integration/features/nip-42/nip-42.feature.ts +++ b/test/integration/features/nip-42/nip-42.feature.ts @@ -4,6 +4,7 @@ import { World, } from '@cucumber/cucumber' import chai from 'chai' + import { EventKinds } from '../../../../src/constants/base' import { SettingsStatic } from '../../../../src/utils/settings' import sinonChai from 'sinon-chai' @@ -18,9 +19,8 @@ Given(/the relay requires the client to authenticate/, async function (this: Wor settings.authentication.enabled = true }) -Then(/(\w+) receives an authentication challenge/, async function (name: string) { +Then(/(\w+) receives an authentication challenge "([^"]+?)"/, async function (name: string) { const ws = this.parameters.clients[name] as WebSocket - const outgoingAuthMessage = await waitForAuth(ws) const event = outgoingAuthMessage[1] expect(event.kind).to.equal(EventKinds.AUTH) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 38aaa854..8c7b6dbe 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -40,6 +40,7 @@ BeforeAll({ timeout: 1000 }, async function () { rrDbClient = getReadReplicaDbClient() await dbClient.raw('SELECT 1=1') Sinon.stub(SettingsStatic, 'watchSettings') + const settings = SettingsStatic.createSettings() SettingsStatic._settings = pipe( From c2fc571adc7db7dc9365362254bbb73c88a82728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Arturo=20Cabral=20Mej=C3=ADa?= Date: Tue, 7 Feb 2023 22:36:46 -0500 Subject: [PATCH 06/10] chore: add authentication settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ricardo Arturo Cabral Mejía --- resources/default-settings.yaml | 2 ++ src/adapters/web-socket-adapter.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 62bd4c4e..f6e3c1ec 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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 diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 8c093268..2cdcc6a8 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -180,6 +180,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8'))) + debug('recv client msg: %o', message) if (!this.authenticated && this.settings().authentication.enabled) { switch(message[0]) { @@ -240,6 +241,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()})`) From 70ace4f7f14730dd18f9392a281dae96da850cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Arturo=20Cabral=20Mej=C3=ADa?= Date: Fri, 10 Feb 2023 11:17:19 -0500 Subject: [PATCH 07/10] test: update integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ricardo Arturo Cabral Mejía --- src/@types/messages.ts | 6 ++++++ src/adapters/web-socket-adapter.ts | 17 ++++------------- src/utils/messages.ts | 6 ++++-- test/integration/features/nip-01/nip-01.feature | 1 - .../features/nip-01/nip-01.feature.ts | 4 ++-- test/integration/features/nip-42/nip-42.feature | 2 +- .../features/nip-42/nip-42.feature.ts | 8 ++++---- test/integration/features/shared.ts | 10 +++++++++- 8 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 534b78e7..7830c807 100644 --- a/src/@types/messages.ts +++ b/src/@types/messages.ts @@ -16,6 +16,7 @@ export enum MessageType { export type IncomingMessage = ( | SubscribeMessage | IncomingEventMessage + | IncomingAuthMessage | UnsubscribeMessage ) & { [ContextMetadataKey]?: ContextMetadata @@ -53,6 +54,11 @@ export interface OutgoingEventMessage { } export interface OutgoingAuthMessage { + 0: MessageType.AUTH + 1: string +} + +export interface IncomingAuthMessage { 0: MessageType.AUTH 1: Event } diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 2cdcc6a8..ee50e134 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -6,7 +6,7 @@ import { randomBytes } from 'crypto' import { WebSocket } from 'ws' import { ContextMetadata, Factory } from '../@types/base' -import { createAuthEventMessage, createCommandResult, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' +import { createAuthMessage, createCommandResult, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' import { IAbortable, IMessageHandler } from '../@types/message-handlers' import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' @@ -188,10 +188,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter // Need to emit event? const challenge = this.setNewAuthChallenge() - this.webSocketServer.emit( - WebSocketServerAdapterEvent.Broadcast, - createAuthEventMessage(challenge) - ) + this.sendMessage(createAuthMessage(challenge)) return } @@ -201,15 +198,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const challenge = this.setNewAuthChallenge() - this.webSocketServer.emit( - WebSocketServerAdapterEvent.Broadcast, - createCommandResult(message[1].id, false, 'rejected: unauthorized') - ) + this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) - this.webSocketServer.emit( - WebSocketServerAdapterEvent.Broadcast, - createAuthEventMessage(challenge) - ) + this.sendMessage(createAuthMessage(challenge)) return } diff --git a/src/utils/messages.ts b/src/utils/messages.ts index a67a235b..8639f70c 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,9 +1,11 @@ import { + CommandResult, EndOfStoredEventsNotice, IncomingEventMessage, IncomingRelayedEventMessage, MessageType, NoticeMessage, + OutgoingAuthMessage, OutgoingMessage, SubscribeMessage, } from '../@types/messages' @@ -30,12 +32,12 @@ export const createEndOfStoredEventsNoticeMessage = ( } // NIP-20 -export const createCommandResult = (eventId: EventId, successful: boolean, message: string) => { +export const createCommandResult = (eventId: EventId, successful: boolean, message: string): CommandResult => { return [MessageType.OK, eventId, successful, message] } // NIP-42 -export const createAuthEventMessage = (challenge) => { +export const createAuthMessage = (challenge: string): OutgoingAuthMessage => { return [MessageType.AUTH, challenge] } diff --git a/test/integration/features/nip-01/nip-01.feature b/test/integration/features/nip-01/nip-01.feature index e8d7264a..be0c2ca9 100644 --- a/test/integration/features/nip-01/nip-01.feature +++ b/test/integration/features/nip-01/nip-01.feature @@ -72,7 +72,6 @@ Feature: NIP-01 And Alice subscribes to text_note events from Bob and set_metadata events from Charlie Then Alice receives 2 events from Bob and Charlie - @test Scenario: Alice is interested in Bob's events from back in November Given someone called Alice And someone called Bob diff --git a/test/integration/features/nip-01/nip-01.feature.ts b/test/integration/features/nip-01/nip-01.feature.ts index 0b3f14cd..a3f3cc9f 100644 --- a/test/integration/features/nip-01/nip-01.feature.ts +++ b/test/integration/features/nip-01/nip-01.feature.ts @@ -101,13 +101,13 @@ When(/(\w+) sends a set_metadata event/, async function(name: string) { this.parameters.events[name].push(event) }) -When(/^(\w+) sends a text_note event with content "([^"]+)"$/, async function(name: string, content: string) { +When(/^(\w+) sends a text_note event with content "([^"]+)"(?:\s+(successfully|unsuccessfully))?$/, async function(name: string, content: string, outcome: string) { const ws = this.parameters.clients[name] as WebSocket const { pubkey, privkey } = this.parameters.identities[name] const event: Event = await createEvent({ pubkey, kind: 1, content }, privkey) - await sendEvent(ws, event) + await sendEvent(ws, event, outcome !== 'unsuccessfully') this.parameters.events[name].push(event) }) diff --git a/test/integration/features/nip-42/nip-42.feature b/test/integration/features/nip-42/nip-42.feature index 0955cc1c..e1782aac 100644 --- a/test/integration/features/nip-42/nip-42.feature +++ b/test/integration/features/nip-42/nip-42.feature @@ -2,5 +2,5 @@ Feature: NIP-42 Scenario: Alice gets an event by ID Given someone called Alice And the relay requires the client to authenticate - When Alice sends a text_note event with content "hello nostr" + When Alice sends a text_note event with content "hello nostr" unsuccessfully Then Alice receives an authentication challenge diff --git a/test/integration/features/nip-42/nip-42.feature.ts b/test/integration/features/nip-42/nip-42.feature.ts index 7bbab8fb..4a4d7088 100644 --- a/test/integration/features/nip-42/nip-42.feature.ts +++ b/test/integration/features/nip-42/nip-42.feature.ts @@ -5,7 +5,6 @@ import { } from '@cucumber/cucumber' import chai from 'chai' -import { EventKinds } from '../../../../src/constants/base' import { SettingsStatic } from '../../../../src/utils/settings' import sinonChai from 'sinon-chai' import { waitForAuth } from '../helpers' @@ -19,10 +18,11 @@ Given(/the relay requires the client to authenticate/, async function (this: Wor settings.authentication.enabled = true }) -Then(/(\w+) receives an authentication challenge "([^"]+?)"/, async function (name: string) { +Then(/(\w+) receives an authentication challenge/, async function (name: string) { const ws = this.parameters.clients[name] as WebSocket const outgoingAuthMessage = await waitForAuth(ws) - const event = outgoingAuthMessage[1] - expect(event.kind).to.equal(EventKinds.AUTH) + const challenge = outgoingAuthMessage[1] + expect(challenge).to.be.a.string + this.parameters.challenges[name].push(challenge) }) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 8c7b6dbe..bc85182f 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -38,7 +38,9 @@ BeforeAll({ timeout: 1000 }, async function () { cacheClient = getCacheClient() dbClient = getMasterDbClient() rrDbClient = getReadReplicaDbClient() - await dbClient.raw('SELECT 1=1') + await dbClient.raw('DELETE FROM events') + await dbClient.raw('DELETE FROM invoices') + await dbClient.raw('DELETE FROM users') Sinon.stub(SettingsStatic, 'watchSettings') const settings = SettingsStatic.createSettings() @@ -66,6 +68,9 @@ Before(function () { this.parameters.subscriptions = {} this.parameters.clients = {} this.parameters.events = {} + this.parameters.challenges = {} + const settings = SettingsStatic.createSettings() + settings.authentication.enabled = false }) After(async function () { @@ -87,6 +92,7 @@ After(async function () { .map(({ pubkey }) => Buffer.from(pubkey, 'hex')), }).del() this.parameters.identities = {} + this.parameters.challenges = {} }) Given(/someone called (\w+)/, async function(name: string) { @@ -95,6 +101,8 @@ Given(/someone called (\w+)/, async function(name: string) { this.parameters.clients[name] = connection this.parameters.subscriptions[name] = [] this.parameters.events[name] = [] + this.parameters.challenges[name] = [] + const subject = new Subject() connection.once('close', subject.next.bind(subject)) From 7e793c63bd8072d38a64648fa0564e9634d1f417 Mon Sep 17 00:00:00 2001 From: antonleviathan Date: Fri, 10 Feb 2023 15:37:46 -0500 Subject: [PATCH 08/10] feat: signed challenge sent integration test --- src/adapters/web-socket-adapter.ts | 20 +++++++++---------- .../features/nip-42/nip-42.feature | 7 +++++++ .../features/nip-42/nip-42.feature.ts | 14 +++++++++++-- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index ee50e134..b60d5571 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,5 +1,5 @@ +import { ContextMetadataKey, EventKinds } from '../constants/base' import cluster from 'cluster' -import { ContextMetadataKey } from '../constants/base' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' import { randomBytes } from 'crypto' @@ -182,31 +182,29 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8'))) debug('recv client msg: %o', message) - if (!this.authenticated && this.settings().authentication.enabled) { + if ( + !this.authenticated + && message[1].kind !== EventKinds.AUTH + && this.settings().authentication.enabled + ) { switch(message[0]) { case MessageType.REQ: { - // Need to emit event? const challenge = this.setNewAuthChallenge() - this.sendMessage(createAuthMessage(challenge)) - return } case MessageType.EVENT: { - // Need to emit event? - const challenge = this.setNewAuthChallenge() - this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) - this.sendMessage(createAuthMessage(challenge)) - return } default: { - this.sendMessage(createNoticeMessage('invalid: asdcf')) + const challenge = this.setNewAuthChallenge() + this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) + this.sendMessage(createAuthMessage(challenge)) return } } diff --git a/test/integration/features/nip-42/nip-42.feature b/test/integration/features/nip-42/nip-42.feature index e1782aac..879fbbe8 100644 --- a/test/integration/features/nip-42/nip-42.feature +++ b/test/integration/features/nip-42/nip-42.feature @@ -4,3 +4,10 @@ Feature: NIP-42 And the relay requires the client to authenticate When Alice sends a text_note event with content "hello nostr" unsuccessfully Then Alice receives an authentication challenge + + Scenario: Alice sends a signed challenge event + Given someone called Alice + And the relay requires the client to authenticate + When Alice sends a text_note event with content "hello nostr" unsuccessfully + And Alice receives an authentication challenge + Then Alice sends a signed_challenge_event diff --git a/test/integration/features/nip-42/nip-42.feature.ts b/test/integration/features/nip-42/nip-42.feature.ts index 4a4d7088..de889cbc 100644 --- a/test/integration/features/nip-42/nip-42.feature.ts +++ b/test/integration/features/nip-42/nip-42.feature.ts @@ -4,10 +4,11 @@ import { World, } from '@cucumber/cucumber' import chai from 'chai' +import sinonChai from 'sinon-chai' +import { createEvent, sendEvent, waitForAuth } from '../helpers' +import { EventKinds } from '../../../../src/constants/base' import { SettingsStatic } from '../../../../src/utils/settings' -import sinonChai from 'sinon-chai' -import { waitForAuth } from '../helpers' import { WebSocket } from 'ws' chai.use(sinonChai) @@ -26,3 +27,12 @@ Then(/(\w+) receives an authentication challenge/, async function (name: string) this.parameters.challenges[name].push(challenge) }) +Then(/(\w+) sends a signed_challenge_event/, async function (name: string) { + const challenge = this.parameters.challenges[name].pop() + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + + const event: any = await createEvent({ pubkey, kind: EventKinds.AUTH, content: challenge }, privkey) + await sendEvent(ws, event, true) + this.parameters.events[name].push(event) +}) From d1535cf76e7d75e2d617ebf6a13d0af33b45f90a Mon Sep 17 00:00:00 2001 From: antonleviathan Date: Fri, 10 Feb 2023 20:03:07 -0500 Subject: [PATCH 09/10] feat: refactor and fix based on integration tests --- src/adapters/web-socket-adapter.ts | 67 ++++++++++--------- src/factories/message-handler-factory.ts | 21 +++--- .../event-strategies/auth-event-strategy.ts | 7 +- src/utils/event.ts | 8 +-- test/integration/features/helpers.ts | 29 ++++++++ .../features/nip-42/nip-42.feature | 8 +++ .../features/nip-42/nip-42.feature.ts | 14 ++-- test/integration/features/shared.ts | 1 + test/unit/utils/event.spec.ts | 46 ++----------- 9 files changed, 107 insertions(+), 94 deletions(-) diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index b60d5571..732d7fe7 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,5 +1,5 @@ -import { ContextMetadataKey, EventKinds } from '../constants/base' import cluster from 'cluster' +import { ContextMetadataKey } from '../constants/base' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' import { randomBytes } from 'crypto' @@ -22,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') @@ -99,7 +98,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.subscriptions.set(subscriptionId, filters) } - public setNewAuthChallenge() { + public setNewAuthChallenge(): string { const challenge = randomBytes(16).toString('hex') this.authChallenge = { createdAt: new Date(), @@ -182,33 +181,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8'))) debug('recv client msg: %o', message) - if ( - !this.authenticated - && message[1].kind !== EventKinds.AUTH - && this.settings().authentication.enabled - ) { - switch(message[0]) { - case MessageType.REQ: { - const challenge = this.setNewAuthChallenge() - this.sendMessage(createAuthMessage(challenge)) - return - } - - case MessageType.EVENT: { - const challenge = this.setNewAuthChallenge() - this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) - this.sendMessage(createAuthMessage(challenge)) - return - } - - default: { - const challenge = this.setNewAuthChallenge() - this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) - this.sendMessage(createAuthMessage(challenge)) - return - } - } - } + const requiresAuthentication = this.isAuthenticationRequired(message) + if (requiresAuthentication) return message[ContextMetadataKey] = { remoteAddress: this.clientAddress, @@ -322,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 + } } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 014b415f..d58c1196 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,12 +1,12 @@ 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' @@ -30,16 +30,6 @@ export const messageHandlerFactory = ( ) } - if (isSignedAuthEvent(message[1])) { - return new AuthEventMessageHandler( - adapter, - signedAuthEventStrategyFactory(), - userRepository, - createSettings, - slidingWindowRateLimiterFactory, - ) - } - return new EventMessageHandler( adapter, eventStrategyFactory(eventRepository), @@ -50,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: diff --git a/src/handlers/event-strategies/auth-event-strategy.ts b/src/handlers/event-strategies/auth-event-strategy.ts index 14d7a7f4..903dd393 100644 --- a/src/handlers/event-strategies/auth-event-strategy.ts +++ b/src/handlers/event-strategies/auth-event-strategy.ts @@ -17,16 +17,17 @@ export class SignedAuthEventStrategy implements IEventStrategy { debug('received signedAuth event: %o', event) const { challenge, createdAt } = this.webSocket.getClientAuthChallengeData() - const verified = await isValidSignedAuthEvent(event, challenge) + const verified = isValidSignedAuthEvent(event, challenge) - const timeIsWithinBounds = (createdAt.getTime() + permittedChallengeResponseTimeDelayMs) < Date.now() + 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, true, 'authentication: failed')) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'authentication: failed')) } } diff --git a/src/utils/event.ts b/src/utils/event.ts index e3b584b9..0ad8cc78 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -140,13 +140,13 @@ export const isSignedAuthEvent = (event: Event): boolean => { return false } -export const isValidSignedAuthEvent = async (event: Event, challenge: string): Promise => { +export const isValidSignedAuthEvent = (event: Event, challenge: string): boolean => { const signedAuthEvent = isSignedAuthEvent(event) if (signedAuthEvent) { - const sig = event.tags.find(tag => tag.length >= 2 && tag[0] === EventTags.Challenge) + const tag = event.tags.find(tag => tag.length >= 2 && tag[0] === EventTags.Challenge) - return secp256k1.schnorr.verify(sig[1], challenge, event.pubkey) + return tag[1] === challenge } return false @@ -327,7 +327,7 @@ export const getEventExpiration = (event: Event): number | undefined => { const expirationTime = Number(rawExpirationTime) if ((Number.isSafeInteger(expirationTime) && Math.log10(expirationTime))) { return expirationTime - } + } } export const getEventProofOfWork = (eventId: EventId): number => { diff --git a/test/integration/features/helpers.ts b/test/integration/features/helpers.ts index 744c5363..8dcf88d7 100644 --- a/test/integration/features/helpers.ts +++ b/test/integration/features/helpers.ts @@ -132,6 +132,35 @@ export async function sendEvent(ws: WebSocket, event: Event, successful = true) }) } + +export async function sendAuthMessage(ws: WebSocket, event: Event, successful = true) { + return new Promise((resolve, reject) => { + const observable = streams.get(ws) as Observable + + const sub = observable.subscribe((message: OutgoingMessage) => { + if (message[0] === MessageType.OK && message[1] === event.id) { + if (message[2] === successful) { + sub.unsubscribe() + resolve(message) + } else { + sub.unsubscribe() + reject(new Error(message[3])) + } + } else if (message[0] === MessageType.NOTICE) { + sub.unsubscribe() + reject(new Error(message[1])) + } + }) + + ws.send(JSON.stringify(['AUTH', event]), (err) => { + if (err) { + sub.unsubscribe() + reject(err) + } + }) + }) +} + export async function waitForNextEvent(ws: WebSocket, subscription: string, content?: string): Promise { return new Promise((resolve, reject) => { const observable = streams.get(ws) as Observable diff --git a/test/integration/features/nip-42/nip-42.feature b/test/integration/features/nip-42/nip-42.feature index 879fbbe8..75cf820c 100644 --- a/test/integration/features/nip-42/nip-42.feature +++ b/test/integration/features/nip-42/nip-42.feature @@ -11,3 +11,11 @@ Feature: NIP-42 When Alice sends a text_note event with content "hello nostr" unsuccessfully And Alice receives an authentication challenge Then Alice sends a signed_challenge_event + + Scenario: Alice authenticates and sends an event + Given someone called Alice + And the relay requires the client to authenticate + When Alice sends a text_note event with content "hello nostr" unsuccessfully + And Alice receives an authentication challenge + Then Alice sends a signed_challenge_event + Then Alice sends a text_note event with content "hello nostr" successfully diff --git a/test/integration/features/nip-42/nip-42.feature.ts b/test/integration/features/nip-42/nip-42.feature.ts index de889cbc..8f3932d2 100644 --- a/test/integration/features/nip-42/nip-42.feature.ts +++ b/test/integration/features/nip-42/nip-42.feature.ts @@ -6,9 +6,10 @@ import { import chai from 'chai' import sinonChai from 'sinon-chai' -import { createEvent, sendEvent, waitForAuth } from '../helpers' -import { EventKinds } from '../../../../src/constants/base' +import { createEvent, sendAuthMessage, waitForAuth } from '../helpers' +import { EventKinds, EventTags } from '../../../../src/constants/base' import { SettingsStatic } from '../../../../src/utils/settings' +import { Tag } from '../../../../src/@types/base' import { WebSocket } from 'ws' chai.use(sinonChai) @@ -31,8 +32,13 @@ Then(/(\w+) sends a signed_challenge_event/, async function (name: string) { const challenge = this.parameters.challenges[name].pop() const ws = this.parameters.clients[name] as WebSocket const { pubkey, privkey } = this.parameters.identities[name] + const tags: Tag[] = [ + [EventTags.Relay, 'ws://yoda.test.relay'], + [EventTags.Challenge, challenge], + ] + + const event: any = await createEvent({ pubkey, kind: EventKinds.AUTH, tags }, privkey) + await sendAuthMessage(ws, event, true) - const event: any = await createEvent({ pubkey, kind: EventKinds.AUTH, content: challenge }, privkey) - await sendEvent(ws, event, true) this.parameters.events[name].push(event) }) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index bc85182f..8b16426c 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -51,6 +51,7 @@ BeforeAll({ timeout: 1000 }, async function () { assocPath( ['limits', 'event', 'rateLimits'], []), assocPath( ['limits', 'invoice', 'rateLimits'], []), assocPath( ['limits', 'connection', 'rateLimits'], []), + assocPath( ['info', 'relay_url'], 'ws://yoda.test.relay'), )(settings) as any worker = workerFactory() diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index 17d66e62..97c8e8b6 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -609,65 +609,27 @@ describe('NIP-40', () => { describe('isValidSignedAuthEvent', async () => { it('returns true if event is valid client auth event', async () => { - const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' - const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' - event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' event.kind = EventKinds.AUTH event.tags = [ [EventTags.Relay, 'wss://eden.nostr.land'], - [EventTags.Challenge, signedHexChallenge], + [EventTags.Challenge, 'test'], ] - expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(true) + expect(await isValidSignedAuthEvent(event, 'test')).to.equal(true) }) }) describe('isValidSignedAuthEvent', async () => { it('returns false if challenge is different', async () => { - const challengeHex = '6468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' - const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' - - event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' - event.kind = EventKinds.AUTH - event.tags = [ - [EventTags.Relay, 'wss://eden.nostr.land'], - [EventTags.Challenge, signedHexChallenge], - ] - - expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false) - }) - }) - - describe('isValidSignedAuthEvent', async () => { - it('returns false if signed challenge is incorrect', async () => { - const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' - const signedHexChallenge = '0161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' - event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' event.kind = EventKinds.AUTH event.tags = [ [EventTags.Relay, 'wss://eden.nostr.land'], - [EventTags.Challenge, signedHexChallenge], - ] - - expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false) - }) - }) - - describe('isValidSignedAuthEvent', async () => { - it('returns true if event is valid client auth event', async () => { - const challengeHex = '7468656368616c6c656e67657249554a415057494f4f414949444f4149414f5039393039' - const signedHexChallenge = '9161696c52b5471c8d3b4a1649f134731136a5237d67e3ad40451e4ecd2bce1f2787d8043a411f7427e1de30bbbb288d00ef5652df0577fa1191e612344ac8b8' - - event.pubkey = 'a9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' - event.kind = EventKinds.AUTH - event.tags = [ - [EventTags.Relay, 'wss://eden.nostr.land'], - [EventTags.Challenge, signedHexChallenge], + [EventTags.Challenge, 'incorrectChallenge'], ] - expect(await isValidSignedAuthEvent(event, challengeHex)).to.equal(false) + expect(isValidSignedAuthEvent(event, 'challenge')).to.equal(false) }) }) }) \ No newline at end of file From 0cf503c95cf3c853ee4b114e4c2a4ad98f80e701 Mon Sep 17 00:00:00 2001 From: antonleviathan Date: Fri, 10 Feb 2023 22:23:23 -0500 Subject: [PATCH 10/10] chore: update readme and package with new nip --- README.md | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 0516da3c..98f5523f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index c9f42980..59ad441d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ 28, 33, 40, + 42, 111 ], "main": "src/index.ts",