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", 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/@types/adapters.ts b/src/@types/adapters.ts index 0e491c3a..c49083e4 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 + getClientAuthChallengeData(): { challenge: string, createdAt: Date } | undefined + setClientToAuthenticated(): void } export interface ICacheAdapter { diff --git a/src/@types/messages.ts b/src/@types/messages.ts index 63f24b62..7830c807 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', @@ -15,6 +16,7 @@ export enum MessageType { export type IncomingMessage = ( | SubscribeMessage | IncomingEventMessage + | IncomingAuthMessage | UnsubscribeMessage ) & { [ContextMetadataKey]?: ContextMetadata @@ -23,6 +25,7 @@ export type IncomingMessage = ( export type OutgoingMessage = | OutgoingEventMessage + | OutgoingAuthMessage | EndOfStoredEventsNotice | NoticeMessage | CommandResult @@ -50,6 +53,16 @@ export interface OutgoingEventMessage { 2: Event } +export interface OutgoingAuthMessage { + 0: MessageType.AUTH + 1: string +} + +export interface IncomingAuthMessage { + 0: MessageType.AUTH + 1: Event +} + export interface UnsubscribeMessage { 0: MessageType.CLOSE 1: SubscriptionId 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 4e584f6b..732d7fe7 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -1,17 +1,18 @@ import cluster from 'cluster' +import { ContextMetadataKey } from '../constants/base' import { EventEmitter } from 'stream' import { IncomingMessage as IncomingHttpMessage } from 'http' +import { randomBytes } from 'crypto' import { WebSocket } from 'ws' import { ContextMetadata, Factory } from '../@types/base' -import { createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' +import { createAuthMessage, createCommandResult, createNoticeMessage, createOutgoingEventMessage } from '../utils/messages' import { IAbortable, IMessageHandler } from '../@types/message-handlers' -import { IncomingMessage, OutgoingMessage } from '../@types/messages' +import { IncomingMessage, MessageType, OutgoingMessage } from '../@types/messages' import { IWebSocketAdapter, IWebSocketServerAdapter } from '../@types/adapters' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../constants/adapter' import { attemptValidation } from '../utils/validation' -import { ContextMetadataKey } from '../constants/base' import { createLogger } from '../factories/logger-factory' import { Event } from '../@types/event' import { getRemoteAddress } from '../utils/http' @@ -21,7 +22,6 @@ import { messageSchema } from '../schemas/message-schema' import { Settings } from '../@types/settings' import { SocketAddress } from 'net' - const debug = createLogger('web-socket-adapter') const debugHeartbeat = debug.extend('heartbeat') @@ -32,6 +32,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private clientAddress: SocketAddress private alive: boolean private subscriptions: Map + private authChallenge: { createdAt: Date, challenge: string } | undefined + private authenticated: boolean public constructor( private readonly client: WebSocket, @@ -96,8 +98,28 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.subscriptions.set(subscriptionId, filters) } + public setNewAuthChallenge(): string { + const challenge = randomBytes(16).toString('hex') + this.authChallenge = { + createdAt: new Date(), + challenge, + } + + return challenge + } + + public setClientToAuthenticated() { + this.authenticated = true + this.authChallenge = undefined + } + + public getClientAuthChallengeData() { + return this.authChallenge + } + public onBroadcast(event: Event): void { this.webSocketServer.emit(WebSocketServerAdapterEvent.Broadcast, event) + if (cluster.isWorker && typeof process.send === 'function') { process.send({ eventName: WebSocketServerAdapterEvent.Broadcast, @@ -157,6 +179,10 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } const message = attemptValidation(messageSchema)(JSON.parse(raw.toString('utf8'))) + debug('recv client msg: %o', message) + + const requiresAuthentication = this.isAuthenticationRequired(message) + if (requiresAuthentication) return message[ContextMetadataKey] = { remoteAddress: this.clientAddress, @@ -178,6 +204,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter await messageHandler.handleMessage(message) } catch (error) { + console.error('mistakes were made', error) if (error instanceof Error) { if (error.name === 'AbortError') { console.error(`web-socket-adapter: abort from client ${this.clientId} (${this.getClientAddress()})`) @@ -251,6 +278,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } private onClientClose() { + this.authenticated = false this.alive = false this.subscriptions.clear() @@ -268,4 +296,37 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter this.removeAllListeners() this.client.removeAllListeners() } + + private isAuthenticationRequired(message): boolean { + if ( + !this.authenticated + && message[0] !== MessageType.AUTH + && message[0] !== MessageType.CLOSE + && this.settings().authentication.enabled + ) { + switch(message[0]) { + case MessageType.REQ: { + const challenge = this.setNewAuthChallenge() + this.sendMessage(createAuthMessage(challenge)) + return true + } + + case MessageType.EVENT: { + const challenge = this.setNewAuthChallenge() + this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) + this.sendMessage(createAuthMessage(challenge)) + return true + } + + default: { + const challenge = this.setNewAuthChallenge() + this.sendMessage(createCommandResult(message[1].id, false, 'rejected: unauthorized')) + this.sendMessage(createAuthMessage(challenge)) + return true + } + } + } + + return false + } } 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..d58c1196 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -1,5 +1,6 @@ import { IEventRepository, IUserRepository } from '../@types/repositories' import { IncomingMessage, MessageType } from '../@types/messages' +import { AuthEventMessageHandler } from '../handlers/auth-event-message-handler' import { createSettings } from './settings-factory' import { DelegatedEventMessageHandler } from '../handlers/delegated-event-message-handler' import { delegatedEventStrategyFactory } from './delegated-event-strategy-factory' @@ -7,6 +8,7 @@ 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' @@ -38,6 +40,15 @@ export const messageHandlerFactory = ( } case MessageType.REQ: return new SubscribeMessageHandler(adapter, eventRepository, createSettings) + case MessageType.AUTH: { + return new AuthEventMessageHandler( + adapter, + signedAuthEventStrategyFactory(), + userRepository, + createSettings, + slidingWindowRateLimiterFactory, + ) + } case MessageType.CLOSE: return new UnsubscribeMessageHandler(adapter,) default: diff --git a/src/handlers/auth-event-message-handler.ts b/src/handlers/auth-event-message-handler.ts new file mode 100644 index 00000000..0838d7e6 --- /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 (!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..903dd393 --- /dev/null +++ b/src/handlers/event-strategies/auth-event-strategy.ts @@ -0,0 +1,33 @@ +import { createCommandResult } from '../../utils/messages' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { IEventStrategy } from '../../@types/message-handlers' +import { isValidSignedAuthEvent } from '../../utils/event' +import { IWebSocketAdapter } from '../../@types/adapters' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const permittedChallengeResponseTimeDelayMs = (1000 * 60 * 10) // 10 min +const debug = createLogger('default-event-strategy') + +export class SignedAuthEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + ) { } + + public async execute(event: Event): Promise { + debug('received signedAuth event: %o', event) + const { challenge, createdAt } = this.webSocket.getClientAuthChallengeData() + const verified = isValidSignedAuthEvent(event, challenge) + + const timeIsWithinBounds = (createdAt.getTime() + permittedChallengeResponseTimeDelayMs) > Date.now() + + debug('banana', timeIsWithinBounds, verified) + if (verified && timeIsWithinBounds) { + this.webSocket.setClientToAuthenticated() + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, true, 'authentication: succeeded')) + return + } + + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, 'authentication: failed')) + } +} diff --git a/src/schemas/message-schema.ts b/src/schemas/message-schema.ts index 2817e089..eb090286 100644 --- a/src/schemas/message-schema.ts +++ b/src/schemas/message-schema.ts @@ -11,6 +11,12 @@ export const eventMessageSchema = Schema.array().ordered( ) .label('EVENT message') +export const authEventMessageSchema = Schema.array().ordered( + Schema.string().valid('AUTH').required(), + eventSchema.required(), +) + .label('AUTH message') + export const reqMessageSchema = Schema.array() .ordered(Schema.string().valid('REQ').required(), Schema.string().max(256).required().label('subscriptionId')) .items(filterSchema.required().label('filter')).max(12) @@ -36,5 +42,9 @@ export const messageSchema = Schema.alternatives() is: Schema.array().ordered(Schema.string().equal(MessageType.CLOSE)).items(Schema.any()), then: closeMessageSchema, }, + { + is: Schema.array().ordered(Schema.string().equal(MessageType.AUTH)).items(Schema.any()), + then: authEventMessageSchema, + }, ], }) diff --git a/src/utils/event.ts b/src/utils/event.ts index 7edd0263..0ad8cc78 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -114,6 +114,44 @@ export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Eve return true } +export const isSignedAuthEvent = (event: Event): boolean => { + 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 + } + + 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 = (event: Event, challenge: string): boolean => { + const signedAuthEvent = isSignedAuthEvent(event) + + if (signedAuthEvent) { + const tag = event.tags.find(tag => tag.length >= 2 && tag[0] === EventTags.Challenge) + + return tag[1] === challenge + } + + return false +} + export const isDelegatedEvent = (event: Event): boolean => { return event.tags.some((tag) => tag.length === 4 && tag[0] === EventTags.Delegation) } @@ -289,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/src/utils/messages.ts b/src/utils/messages.ts index a0971e26..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,10 +32,15 @@ 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 createAuthMessage = (challenge: string): OutgoingAuthMessage => { + 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..8dcf88d7 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' @@ -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 @@ -195,6 +224,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-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 e6ba2063..a3f3cc9f 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() @@ -94,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 new file mode 100644 index 00000000..75cf820c --- /dev/null +++ b/test/integration/features/nip-42/nip-42.feature @@ -0,0 +1,21 @@ +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" 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 + + 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 new file mode 100644 index 00000000..8f3932d2 --- /dev/null +++ b/test/integration/features/nip-42/nip-42.feature.ts @@ -0,0 +1,44 @@ +import { + Given, + Then, + World, +} from '@cucumber/cucumber' +import chai from 'chai' +import sinonChai from 'sinon-chai' + +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) +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 challenge = outgoingAuthMessage[1] + expect(challenge).to.be.a.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 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) + + this.parameters.events[name].push(event) +}) diff --git a/test/integration/features/shared.ts b/test/integration/features/shared.ts index 38aaa854..8b16426c 100644 --- a/test/integration/features/shared.ts +++ b/test/integration/features/shared.ts @@ -38,8 +38,11 @@ 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() SettingsStatic._settings = pipe( @@ -48,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() @@ -65,6 +69,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 () { @@ -86,6 +93,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) { @@ -94,6 +102,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)) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index 0009decb..97c8e8b6 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,70 @@ 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 () => { + event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, 'test'], + ] + + expect(await isValidSignedAuthEvent(event, 'test')).to.equal(true) + }) + }) + + describe('isValidSignedAuthEvent', async () => { + it('returns false if challenge is different', async () => { + event.pubkey = 'c9892fe983a9d85a570c706a582db6288a6a53102efee28871a5a6048a579154' + event.kind = EventKinds.AUTH + event.tags = [ + [EventTags.Relay, 'wss://eden.nostr.land'], + [EventTags.Challenge, 'incorrectChallenge'], + ] + + expect(isValidSignedAuthEvent(event, 'challenge')).to.equal(false) + }) + }) }) \ No newline at end of file