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