From 320d67389b593366817d5ff7fe88128e9dc97044 Mon Sep 17 00:00:00 2001 From: Anton Livaja Date: Tue, 7 Feb 2023 19:38:37 -0500 Subject: [PATCH] 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 bbd9b02..c49083e 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 274b6fa..534b78e 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 818e596..b234997 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 7f359bd..cc36056 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 cb20822..14d7a7f 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 - 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) + let relay + let challenge + for (let i = 0; i < event.tags.length; i++) { + const tag = event.tags[i] + if (tag.length < 2) { + continue + } - return Boolean(evenKindIsValid && hasAuthDeclaration && relay && challenge) + if (tag[0] === EventTags.Challenge) { + if (relay) return true + challenge = true + } + + 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 a0971e2..a67a235 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 26a2e37..744c536 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 0000000..0955cc1 --- /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 0000000..89f1841 --- /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 0009dec..17d66e6 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