diff --git a/package-lock.json b/package-lock.json index b573c896..50949533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,10 @@ "bech32": "2.0.0", "debug": "4.3.4", "dotenv": "16.0.3", - "express": "^4.22.1", + "express": "4.22.1", "helmet": "6.0.1", "joi": "17.7.0", - "js-yaml": "^4.1.1", + "js-yaml": "4.1.1", "knex": "2.4.2", "pg": "8.9.0", "pg-query-stream": "4.3.0", @@ -39,7 +39,7 @@ "@types/chai": "^4.3.1", "@types/chai-as-promised": "^7.1.5", "@types/debug": "4.1.7", - "@types/express": "^4.17.21", + "@types/express": "4.17.21", "@types/js-yaml": "4.0.5", "@types/mocha": "^9.1.1", "@types/node": "^24.0.0", diff --git a/package.json b/package.json index 85abc43e..5822d20d 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,13 @@ 12, 15, 16, + 17, 20, 22, 28, 33, - 40 + 40, + 44 ], "supportedNipExtensions": [ "11a" diff --git a/src/app/static-mirroring-worker.ts b/src/app/static-mirroring-worker.ts index 229f1416..aba9b09d 100644 --- a/src/app/static-mirroring-worker.ts +++ b/src/app/static-mirroring-worker.ts @@ -1,340 +1,345 @@ -import { anyPass, map, mergeDeepRight, path } from 'ramda' -import { RawData, WebSocket } from 'ws' -import cluster from 'cluster' -import { randomUUID } from 'crypto' - -import { createRelayedEventMessage, createSubscriptionMessage } from '../utils/messages' -import { EventLimits, FeeSchedule, Mirror, Settings } from '../@types/settings' -import { getEventExpiration, getEventProofOfWork, getPubkeyProofOfWork, getPublicKey, getRelayPrivateKey, isEventIdValid, isEventKindOrRangeMatch, isEventMatchingFilter, isEventSignatureValid, isExpiredEvent } from '../utils/event' -import { IEventRepository, IUserRepository } from '../@types/repositories' -import { createLogger } from '../factories/logger-factory' -import { Event } from '../@types/event' -import { EventExpirationTimeMetadataKey } from '../constants/base' -import { IRunnable } from '../@types/base' -import { OutgoingEventMessage } from '../@types/messages' -import { RelayedEvent } from '../@types/event' -import { WebSocketServerAdapterEvent } from '../constants/adapter' - -const debug = createLogger('static-mirror-worker') - -export class StaticMirroringWorker implements IRunnable { - private client: WebSocket | undefined - private config: Mirror - - public constructor( - private readonly eventRepository: IEventRepository, - private readonly userRepository: IUserRepository, - private readonly process: NodeJS.Process, - private readonly settings: () => Settings, - ) { - this.process - .on('message', this.onMessage.bind(this)) - .on('SIGINT', this.onExit.bind(this)) - .on('SIGHUP', this.onExit.bind(this)) - .on('SIGTERM', this.onExit.bind(this)) - .on('uncaughtException', this.onError.bind(this)) - .on('unhandledRejection', this.onError.bind(this)) - } - - public run(): void { - const currentSettings = this.settings() - - console.log('mirroring', currentSettings.mirroring) - - this.config = path(['mirroring', 'static', process.env.MIRROR_INDEX], currentSettings) as Mirror - - let since = Math.floor(Date.now() / 1000) - 60*10 - - const createMirror = (config: Mirror) => { - const subscriptionId = `mirror-${randomUUID()}` - - debug('connecting to %s', config.address) - - return new WebSocket(config.address, { timeout: 5000 }) - .on('open', function () { - debug('connected to %s', config.address) - - if (Array.isArray(config.filters) && config.filters?.length) { - const filters = config.filters.map((filter) => ({ ...filter, since })) - - debug('subscribing with %s: %o', subscriptionId, filters) - - this.send(JSON.stringify(createSubscriptionMessage(subscriptionId, filters))) - } - }) - .on('message', async (raw: RawData) => { - try { - const message = JSON.parse(raw.toString('utf8')) as OutgoingEventMessage - - if (!Array.isArray(message)) { - return - } - - if (message[0] !== 'EVENT' || message[1] !== subscriptionId) { - debug('%s >> local: %o', config.address, message) - return - } - - let event = message[2] - - if (!anyPass(map(isEventMatchingFilter, config.filters))(event)) { - return - } - - if (!await isEventIdValid(event) || !await isEventSignatureValid(event)) { - return - } - - if (isExpiredEvent(event)) { - return - } - - const eventExpiration = getEventExpiration(event) - if (eventExpiration) { - event = { - ...event, - [EventExpirationTimeMetadataKey]: eventExpiration, - } as any - } - - if (!this.canAcceptEvent(event)) { - return - } - - if (!await this.isUserAdmitted(event)) { - return - } - - since = Math.floor(Date.now() / 1000) - 30 - - debug('%s >> local: %s', config.address, event.id) - - const inserted = await this.eventRepository.create(event) - - if (inserted && cluster.isWorker && typeof process.send === 'function') { - - process.send({ - eventName: WebSocketServerAdapterEvent.Broadcast, - event, - source: config.address, - }) - } - } catch (error) { - debug('unable to process message: %o', error) - } - }) - .on('close', (code, reason) => { - debug(`disconnected (${code}): ${reason.toString()}`) - - setTimeout(() => { - this.client.removeAllListeners() - this.client = createMirror(config) - }, 5000) - }) - .on('error', function (error) { - debug('connection error: %o', error) - }) - } - - this.client = createMirror(this.config) - } - - private getRelayPublicKey(): string { - const relayPrivkey = getRelayPrivateKey(this.settings().info.relay_url) - return getPublicKey(relayPrivkey) - } - - private canAcceptEvent(event: Event): boolean { - if (this.getRelayPublicKey() === event.pubkey) { - debug(`event ${event.id} not accepted: pubkey is relay pubkey`) - return false - } - - const now = Math.floor(Date.now() / 1000) - - const eventLimits = this.settings().limits?.event ?? {} - - const eventLimitOverrides = this.config.limits.event ?? {} - - const limits = mergeDeepRight(eventLimits, eventLimitOverrides) as EventLimits - - if (Array.isArray(limits.content)) { - for (const limit of limits.content) { - if ( - typeof limit.maxLength !== 'undefined' - && limit.maxLength > 0 - && event.content.length > limit.maxLength - && ( - !Array.isArray(limit.kinds) - || limit.kinds.some(isEventKindOrRangeMatch(event)) - ) - ) { - debug(`event ${event.id} not accepted: content is longer than ${limit.maxLength} bytes`) - return false - } - } - } else if ( - typeof limits.content?.maxLength !== 'undefined' - && limits.content?.maxLength > 0 - && event.content.length > limits.content.maxLength - && ( - !Array.isArray(limits.content.kinds) - || limits.content.kinds.some(isEventKindOrRangeMatch(event)) - ) - ) { - debug(`event ${event.id} not accepted: content is longer than ${limits.content.maxLength} bytes`) - return false - } - - if ( - typeof limits.createdAt?.maxPositiveDelta !== 'undefined' - && limits.createdAt.maxPositiveDelta > 0 - && event.created_at > now + limits.createdAt.maxPositiveDelta) { - debug(`event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future`) - return false - } - - if ( - typeof limits.createdAt?.maxNegativeDelta !== 'undefined' - && limits.createdAt.maxNegativeDelta > 0 - && event.created_at < now - limits.createdAt.maxNegativeDelta) { - debug(`event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`) - return false - } - - if ( - typeof limits.eventId?.minLeadingZeroBits !== 'undefined' - && limits.eventId.minLeadingZeroBits > 0 - ) { - const pow = getEventProofOfWork(event.id) - if (pow < limits.eventId.minLeadingZeroBits) { - debug(`event ${event.id} not accepted: pow difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`) - return false - } - } - - if ( - typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' - && limits.pubkey.minLeadingZeroBits > 0 - ) { - const pow = getPubkeyProofOfWork(event.pubkey) - if (pow < limits.pubkey.minLeadingZeroBits) { - debug(`event ${event.id} not accepted: pow pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`) - return false - } - } - - if ( - typeof limits.pubkey?.whitelist !== 'undefined' - && limits.pubkey.whitelist.length > 0 - && !limits.pubkey.whitelist.some((prefix) => event.pubkey.startsWith(prefix)) - ) { - debug(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`) - return false - } - - if ( - typeof limits.pubkey?.blacklist !== 'undefined' - && limits.pubkey.blacklist.length > 0 - && limits.pubkey.blacklist.some((prefix) => event.pubkey.startsWith(prefix)) - ) { - debug(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`) - return false - } - - if ( - typeof limits.kind?.whitelist !== 'undefined' - && limits.kind.whitelist.length > 0 - && !limits.kind.whitelist.some(isEventKindOrRangeMatch(event))) { - debug(`blocked: event kind ${event.kind} not allowed`) - return false - } - - if ( - typeof limits.kind?.blacklist !== 'undefined' - && limits.kind.blacklist.length > 0 - && limits.kind.blacklist.some(isEventKindOrRangeMatch(event))) { - debug(`blocked: event kind ${event.kind} not allowed`) - return false - } - - return true - } - - protected async isUserAdmitted(event: Event): Promise { - const currentSettings = this.settings() - - if (this.config.skipAdmissionCheck === true) { - return true - } - - if (currentSettings.payments?.enabled !== true) { - return true - } - - const isApplicableFee = (feeSchedule: FeeSchedule) => - feeSchedule.enabled - && !feeSchedule.whitelists?.pubkeys?.some((prefix) => event.pubkey.startsWith(prefix)) - && !feeSchedule.whitelists?.event_kinds?.some(isEventKindOrRangeMatch(event)) - - const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) - - if (!Array.isArray(feeSchedules) || !feeSchedules.length) { - return true - } - - const user = await this.userRepository.findByPubkey(event.pubkey) - if (user?.isAdmitted !== true) { - debug(`user not admitted: ${event.pubkey}`) - return false - } - - const minBalance = currentSettings.limits?.event?.pubkey?.minBalance - if (minBalance && user.balance < minBalance) { - debug(`user not admitted: user balance ${user.balance} < ${minBalance}`) - return false - } - - return true - } - - private onMessage(message: { eventName: string, event: unknown, source: string }): void { - if ( - message.eventName !== WebSocketServerAdapterEvent.Broadcast - || message.source === this.config.address - || !this.client - || this.client.readyState !== WebSocket.OPEN - ) { - return - } - - const event = message.event as RelayedEvent - - const eventToRelay = createRelayedEventMessage(event, this.config.secret) - const outboundMessage = JSON.stringify(eventToRelay) - debug('%s >> %s: %s', message.source ?? 'local', this.config.address, outboundMessage) - this.client.send(outboundMessage) - } - - private onError(error: Error) { - debug('error: %o', error) - throw error - } - - private onExit() { - debug('exiting') - this.close(() => { - this.process.exit(0) - }) - } - - public close(callback?: () => void) { - debug('closing') - if (this.client) { - this.client.terminate() - } - if (typeof callback === 'function') { - callback() - } - } -} +import { anyPass, map, mergeDeepRight, path } from 'ramda' +import { RawData, WebSocket } from 'ws' +import cluster from 'cluster' +import { randomUUID } from 'crypto' + +import { createRelayedEventMessage, createSubscriptionMessage } from '../utils/messages' +import { EventLimits, FeeSchedule, Mirror, Settings } from '../@types/settings' +import { getEventExpiration, getEventProofOfWork, getPubkeyProofOfWork, getPublicKey, getRelayPrivateKey, isDirectMessageEvent, isEventIdValid, isEventKindOrRangeMatch, isEventMatchingFilter, isEventSignatureValid, isExpiredEvent, isFileMessageEvent, isSealEvent } from '../utils/event' +import { IEventRepository, IUserRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { Event } from '../@types/event' +import { EventExpirationTimeMetadataKey } from '../constants/base' +import { IRunnable } from '../@types/base' +import { OutgoingEventMessage } from '../@types/messages' +import { RelayedEvent } from '../@types/event' +import { WebSocketServerAdapterEvent } from '../constants/adapter' + +const debug = createLogger('static-mirror-worker') + +export class StaticMirroringWorker implements IRunnable { + private client: WebSocket | undefined + private config: Mirror + + public constructor( + private readonly eventRepository: IEventRepository, + private readonly userRepository: IUserRepository, + private readonly process: NodeJS.Process, + private readonly settings: () => Settings, + ) { + this.process + .on('message', this.onMessage.bind(this)) + .on('SIGINT', this.onExit.bind(this)) + .on('SIGHUP', this.onExit.bind(this)) + .on('SIGTERM', this.onExit.bind(this)) + .on('uncaughtException', this.onError.bind(this)) + .on('unhandledRejection', this.onError.bind(this)) + } + + public run(): void { + const currentSettings = this.settings() + + console.log('mirroring', currentSettings.mirroring) + + this.config = path(['mirroring', 'static', process.env.MIRROR_INDEX], currentSettings) as Mirror + + let since = Math.floor(Date.now() / 1000) - 60*10 + + const createMirror = (config: Mirror) => { + const subscriptionId = `mirror-${randomUUID()}` + + debug('connecting to %s', config.address) + + return new WebSocket(config.address, { timeout: 5000 }) + .on('open', function () { + debug('connected to %s', config.address) + + if (Array.isArray(config.filters) && config.filters?.length) { + const filters = config.filters.map((filter) => ({ ...filter, since })) + + debug('subscribing with %s: %o', subscriptionId, filters) + + this.send(JSON.stringify(createSubscriptionMessage(subscriptionId, filters))) + } + }) + .on('message', async (raw: RawData) => { + try { + const message = JSON.parse(raw.toString('utf8')) as OutgoingEventMessage + + if (!Array.isArray(message)) { + return + } + + if (message[0] !== 'EVENT' || message[1] !== subscriptionId) { + debug('%s >> local: %o', config.address, message) + return + } + + let event = message[2] + + if (!anyPass(map(isEventMatchingFilter, config.filters))(event)) { + return + } + + if (!await isEventIdValid(event) || !await isEventSignatureValid(event)) { + return + } + + if (isExpiredEvent(event)) { + return + } + + const eventExpiration = getEventExpiration(event) + if (eventExpiration) { + event = { + ...event, + [EventExpirationTimeMetadataKey]: eventExpiration, + } as any + } + + if (!this.canAcceptEvent(event)) { + return + } + + if (!await this.isUserAdmitted(event)) { + return + } + + // NIP-17: inner events (kind 13, 14, 15) must never be stored directly + if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) { + return + } + + since = Math.floor(Date.now() / 1000) - 30 + + debug('%s >> local: %s', config.address, event.id) + + const inserted = await this.eventRepository.create(event) + + if (inserted && cluster.isWorker && typeof process.send === 'function') { + + process.send({ + eventName: WebSocketServerAdapterEvent.Broadcast, + event, + source: config.address, + }) + } + } catch (error) { + debug('unable to process message: %o', error) + } + }) + .on('close', (code, reason) => { + debug(`disconnected (${code}): ${reason.toString()}`) + + setTimeout(() => { + this.client.removeAllListeners() + this.client = createMirror(config) + }, 5000) + }) + .on('error', function (error) { + debug('connection error: %o', error) + }) + } + + this.client = createMirror(this.config) + } + + private getRelayPublicKey(): string { + const relayPrivkey = getRelayPrivateKey(this.settings().info.relay_url) + return getPublicKey(relayPrivkey) + } + + private canAcceptEvent(event: Event): boolean { + if (this.getRelayPublicKey() === event.pubkey) { + debug(`event ${event.id} not accepted: pubkey is relay pubkey`) + return false + } + + const now = Math.floor(Date.now() / 1000) + + const eventLimits = this.settings().limits?.event ?? {} + + const eventLimitOverrides = this.config.limits.event ?? {} + + const limits = mergeDeepRight(eventLimits, eventLimitOverrides) as EventLimits + + if (Array.isArray(limits.content)) { + for (const limit of limits.content) { + if ( + typeof limit.maxLength !== 'undefined' + && limit.maxLength > 0 + && event.content.length > limit.maxLength + && ( + !Array.isArray(limit.kinds) + || limit.kinds.some(isEventKindOrRangeMatch(event)) + ) + ) { + debug(`event ${event.id} not accepted: content is longer than ${limit.maxLength} bytes`) + return false + } + } + } else if ( + typeof limits.content?.maxLength !== 'undefined' + && limits.content?.maxLength > 0 + && event.content.length > limits.content.maxLength + && ( + !Array.isArray(limits.content.kinds) + || limits.content.kinds.some(isEventKindOrRangeMatch(event)) + ) + ) { + debug(`event ${event.id} not accepted: content is longer than ${limits.content.maxLength} bytes`) + return false + } + + if ( + typeof limits.createdAt?.maxPositiveDelta !== 'undefined' + && limits.createdAt.maxPositiveDelta > 0 + && event.created_at > now + limits.createdAt.maxPositiveDelta) { + debug(`event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxPositiveDelta} seconds in the future`) + return false + } + + if ( + typeof limits.createdAt?.maxNegativeDelta !== 'undefined' + && limits.createdAt.maxNegativeDelta > 0 + && event.created_at < now - limits.createdAt.maxNegativeDelta) { + debug(`event ${event.id} not accepted: created_at is more than ${limits.createdAt.maxNegativeDelta} seconds in the past`) + return false + } + + if ( + typeof limits.eventId?.minLeadingZeroBits !== 'undefined' + && limits.eventId.minLeadingZeroBits > 0 + ) { + const pow = getEventProofOfWork(event.id) + if (pow < limits.eventId.minLeadingZeroBits) { + debug(`event ${event.id} not accepted: pow difficulty ${pow}<${limits.eventId.minLeadingZeroBits}`) + return false + } + } + + if ( + typeof limits.pubkey?.minLeadingZeroBits !== 'undefined' + && limits.pubkey.minLeadingZeroBits > 0 + ) { + const pow = getPubkeyProofOfWork(event.pubkey) + if (pow < limits.pubkey.minLeadingZeroBits) { + debug(`event ${event.id} not accepted: pow pubkey difficulty ${pow}<${limits.pubkey.minLeadingZeroBits}`) + return false + } + } + + if ( + typeof limits.pubkey?.whitelist !== 'undefined' + && limits.pubkey.whitelist.length > 0 + && !limits.pubkey.whitelist.some((prefix) => event.pubkey.startsWith(prefix)) + ) { + debug(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`) + return false + } + + if ( + typeof limits.pubkey?.blacklist !== 'undefined' + && limits.pubkey.blacklist.length > 0 + && limits.pubkey.blacklist.some((prefix) => event.pubkey.startsWith(prefix)) + ) { + debug(`event ${event.id} not accepted: pubkey not allowed: ${event.pubkey}`) + return false + } + + if ( + typeof limits.kind?.whitelist !== 'undefined' + && limits.kind.whitelist.length > 0 + && !limits.kind.whitelist.some(isEventKindOrRangeMatch(event))) { + debug(`blocked: event kind ${event.kind} not allowed`) + return false + } + + if ( + typeof limits.kind?.blacklist !== 'undefined' + && limits.kind.blacklist.length > 0 + && limits.kind.blacklist.some(isEventKindOrRangeMatch(event))) { + debug(`blocked: event kind ${event.kind} not allowed`) + return false + } + + return true + } + + protected async isUserAdmitted(event: Event): Promise { + const currentSettings = this.settings() + + if (this.config.skipAdmissionCheck === true) { + return true + } + + if (currentSettings.payments?.enabled !== true) { + return true + } + + const isApplicableFee = (feeSchedule: FeeSchedule) => + feeSchedule.enabled + && !feeSchedule.whitelists?.pubkeys?.some((prefix) => event.pubkey.startsWith(prefix)) + && !feeSchedule.whitelists?.event_kinds?.some(isEventKindOrRangeMatch(event)) + + const feeSchedules = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) + + if (!Array.isArray(feeSchedules) || !feeSchedules.length) { + return true + } + + const user = await this.userRepository.findByPubkey(event.pubkey) + if (user?.isAdmitted !== true) { + debug(`user not admitted: ${event.pubkey}`) + return false + } + + const minBalance = currentSettings.limits?.event?.pubkey?.minBalance + if (minBalance && user.balance < minBalance) { + debug(`user not admitted: user balance ${user.balance} < ${minBalance}`) + return false + } + + return true + } + + private onMessage(message: { eventName: string, event: unknown, source: string }): void { + if ( + message.eventName !== WebSocketServerAdapterEvent.Broadcast + || message.source === this.config.address + || !this.client + || this.client.readyState !== WebSocket.OPEN + ) { + return + } + + const event = message.event as RelayedEvent + + const eventToRelay = createRelayedEventMessage(event, this.config.secret) + const outboundMessage = JSON.stringify(eventToRelay) + debug('%s >> %s: %s', message.source ?? 'local', this.config.address, outboundMessage) + this.client.send(outboundMessage) + } + + private onError(error: Error) { + debug('error: %o', error) + throw error + } + + private onExit() { + debug('exiting') + this.close(() => { + this.process.exit(0) + }) + } + + public close(callback?: () => void) { + debug('closing') + if (this.client) { + this.client.terminate() + } + if (typeof callback === 'function') { + callback() + } + } +} diff --git a/src/constants/base.ts b/src/constants/base.ts index 974e6c39..b69866cd 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -7,6 +7,10 @@ export enum EventKinds { DELETE = 5, REPOST = 6, REACTION = 7, + // NIP-17: Private Direct Messages + SEAL = 13, + DIRECT_MESSAGE = 14, + FILE_MESSAGE = 15, REQUEST_TO_VANISH = 62, // Channels CHANNEL_CREATION = 40, @@ -16,6 +20,8 @@ export enum EventKinds { CHANNEL_MUTE_USER = 44, CHANNEL_RESERVED_FIRST = 45, CHANNEL_RESERVED_LAST = 49, + // NIP-17: Gift Wrap + GIFT_WRAP = 1059, // Relay-only RELAY_INVITE = 50, INVOICE_UPDATE = 402, diff --git a/src/factories/event-strategy-factory.ts b/src/factories/event-strategy-factory.ts index 1e4b5af9..45180fe4 100644 --- a/src/factories/event-strategy-factory.ts +++ b/src/factories/event-strategy-factory.ts @@ -1,9 +1,10 @@ -import { isDeleteEvent, isEphemeralEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent } from '../utils/event' +import { isDeleteEvent, isEphemeralEvent, isGiftWrapEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent } from '../utils/event' import { DefaultEventStrategy } from '../handlers/event-strategies/default-event-strategy' import { DeleteEventStrategy } from '../handlers/event-strategies/delete-event-strategy' import { EphemeralEventStrategy } from '../handlers/event-strategies/ephemeral-event-strategy' import { Event } from '../@types/event' import { Factory } from '../@types/base' +import { GiftWrapEventStrategy } from '../handlers/event-strategies/gift-wrap-event-strategy' import { IEventRepository } from '../@types/repositories' import { IEventStrategy } from '../@types/message-handlers' import { IWebSocketAdapter } from '../@types/adapters' @@ -17,6 +18,8 @@ export const eventStrategyFactory = ( ([event, adapter]: [Event, IWebSocketAdapter]) => { if (isRequestToVanishEvent(event)) { return new VanishEventStrategy(adapter, eventRepository) + } else if (isGiftWrapEvent(event)) { + return new GiftWrapEventStrategy(adapter, eventRepository) } else if (isReplaceableEvent(event)) { return new ReplaceableEventStrategy(adapter, eventRepository) } else if (isEphemeralEvent(event)) { @@ -25,7 +28,7 @@ export const eventStrategyFactory = ( return new DeleteEventStrategy(adapter, eventRepository) } else if (isParameterizedReplaceableEvent(event)) { return new ParameterizedReplaceableEventStrategy(adapter, eventRepository) - } + } return new DefaultEventStrategy(adapter, eventRepository) } diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 43d1ba22..238e121f 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -7,11 +7,14 @@ import { getPubkeyProofOfWork, getPublicKey, getRelayPrivateKey, + isDirectMessageEvent, isEventIdValid, isEventKindOrRangeMatch, isEventSignatureValid, isExpiredEvent, + isFileMessageEvent, isRequestToVanishEvent, + isSealEvent, } from '../utils/event' import { IEventRepository, IUserRepository } from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' @@ -212,6 +215,13 @@ export class EventMessageHandler implements IMessageHandler { if (event.kind === EventKinds.REQUEST_TO_VANISH && !isRequestToVanishEvent(event, this.settings().info.relay_url)) { return 'invalid: request to vanish relay tag invalid' } + + // NIP-17: kind 13 (Seal) and kind 14 (Direct Message) are inner events that + // must never be published directly to a relay. They are encrypted inside a + // kind 1059 Gift Wrap (NIP-59) before being sent here. + if (isSealEvent(event) || isDirectMessageEvent(event) || isFileMessageEvent(event)) { + return `blocked: kind ${event.kind} events must not be published directly; wrap them in a kind 1059 gift wrap` + } } protected async isBlockedByRequestToVanish(event: Event): Promise { diff --git a/src/handlers/event-strategies/gift-wrap-event-strategy.ts b/src/handlers/event-strategies/gift-wrap-event-strategy.ts new file mode 100644 index 00000000..ee46006c --- /dev/null +++ b/src/handlers/event-strategies/gift-wrap-event-strategy.ts @@ -0,0 +1,69 @@ +import { createCommandResult } from '../../utils/messages' +import { createLogger } from '../../factories/logger-factory' +import { Event } from '../../@types/event' +import { EventTags } from '../../constants/base' +import { IEventRepository } from '../../@types/repositories' +import { IEventStrategy } from '../../@types/message-handlers' +import { IWebSocketAdapter } from '../../@types/adapters' +import { validateNip44Payload } from '../../utils/nip44' +import { WebSocketAdapterEvent } from '../../constants/adapter' + +const debug = createLogger('gift-wrap-event-strategy') + +export class GiftWrapEventStrategy implements IEventStrategy> { + public constructor( + private readonly webSocket: IWebSocketAdapter, + private readonly eventRepository: IEventRepository, + ) {} + + public async execute(event: Event): Promise { + debug('received gift wrap event: %o', event) + + const reason = this.validateGiftWrap(event) + if (reason) { + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, false, `invalid: ${reason}`), + ) + return + } + + const count = await this.eventRepository.create(event) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createCommandResult(event.id, true, count ? '' : 'duplicate:'), + ) + + if (count) { + this.webSocket.emit(WebSocketAdapterEvent.Broadcast, event) + } + } + + private validateGiftWrap(event: Event): string | undefined { + // NIP-17: gift wrap MUST have exactly one p tag (one recipient per wrap) + const recipientTags = event.tags.filter( + (tag) => tag.length >= 2 && tag[0] === EventTags.Pubkey, + ) + + if (recipientTags.length === 0) { + return 'gift wrap event (kind 1059) must have a p tag identifying the recipient' + } + + if (recipientTags.length > 1) { + return 'gift wrap event (kind 1059) must have exactly one p tag' + } + + const recipientPubkey = recipientTags[0][1] + if (!/^[0-9a-f]{64}$/.test(recipientPubkey)) { + return 'gift wrap event (kind 1059) p tag must contain a valid 64-character lowercase hex pubkey' + } + + // Validate that the content is a structurally valid NIP-44 v2 payload + const payloadError = validateNip44Payload(event.content) + if (payloadError) { + return `gift wrap content must be a valid NIP-44 v2 payload: ${payloadError}` + } + + return undefined + } +} diff --git a/src/utils/event.ts b/src/utils/event.ts index 1bdde933..50ab0b73 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -2,7 +2,6 @@ import * as secp256k1 from '@noble/secp256k1' import { ALL_RELAYS, EventKinds, EventTags } from '../constants/base' import { applySpec, pipe, prop } from 'ramda' import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event' -import { createCipheriv, getRandomValues } from 'crypto' import { EventId, Pubkey, Tag } from '../@types/base' import cluster from 'cluster' import { deriveFromSecret } from './secret' @@ -155,33 +154,6 @@ export const signEvent = (privkey: string | Buffer | undefined) => async (event: return { ...event, sig: Buffer.from(sig).toString('hex') } } -export const encryptKind4Event = ( - senderPrivkey: string | Buffer, - receiverPubkey: Pubkey, -) => (event: UnsignedEvent): UnsignedEvent => { - const key = secp256k1 - .getSharedSecret(senderPrivkey, `02${receiverPubkey}`, true) - .subarray(1) - - const iv = getRandomValues(new Uint8Array(16)) - - // deepcode ignore InsecureCipherNoIntegrity: NIP-04 Encrypted Direct Message uses aes-256-cbc - const cipher = createCipheriv( - 'aes-256-cbc', - Buffer.from(key), - iv, - ) - - let content = cipher.update(event.content, 'utf8', 'base64') - content += cipher.final('base64') - content += '?iv=' + Buffer.from(iv.buffer).toString('base64') - - return { - ...event, - content, - } -} - export const broadcastEvent = async (event: Event): Promise => { return new Promise((resolve, reject) => { if (!cluster.isWorker || typeof process.send === 'undefined') { @@ -275,3 +247,21 @@ export const getEventProofOfWork = (eventId: EventId): number => { export const getPubkeyProofOfWork = (pubkey: Pubkey): number => { return getLeadingZeroBits(Buffer.from(pubkey, 'hex')) } + +// NIP-17: Private Direct Messages helpers + +export const isGiftWrapEvent = (event: Event): boolean => { + return event.kind === EventKinds.GIFT_WRAP +} + +export const isSealEvent = (event: Event): boolean => { + return event.kind === EventKinds.SEAL +} + +export const isDirectMessageEvent = (event: Event): boolean => { + return event.kind === EventKinds.DIRECT_MESSAGE +} + +export const isFileMessageEvent = (event: Event): boolean => { + return event.kind === EventKinds.FILE_MESSAGE +} diff --git a/src/utils/nip44.ts b/src/utils/nip44.ts new file mode 100644 index 00000000..dd7edfde --- /dev/null +++ b/src/utils/nip44.ts @@ -0,0 +1,164 @@ +import * as secp256k1 from '@noble/secp256k1' +import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from 'crypto' + +const MIN_PLAINTEXT_SIZE = 1 +const MAX_PLAINTEXT_SIZE = 65535 + +// HKDF-extract: PRK = HMAC-SHA256(salt, IKM) +function hkdfExtract(salt: Buffer, ikm: Buffer): Buffer { + return createHmac('sha256', salt).update(ikm).digest() +} + +// HKDF-expand: OKM = T(1) || T(2) || ... where T(n) = HMAC-SHA256(PRK, T(n-1) || info || n) +function hkdfExpand(prk: Buffer, info: Buffer, length: number): Buffer { + const hashLen = 32 + const n = Math.ceil(length / hashLen) + const okm = Buffer.alloc(n * hashLen) + let prev = Buffer.alloc(0) + + for (let i = 1; i <= n; i++) { + const hmac = createHmac('sha256', prk) + hmac.update(prev) + hmac.update(info) + hmac.update(Buffer.from([i])) + prev = hmac.digest() + prev.copy(okm, (i - 1) * hashLen) + } + + return okm.subarray(0, length) +} + +/** + * Derive a conversation key from a sender private key and recipient public key. + * conversation_key = HKDF-extract(IKM=shared_x, salt='nip44-v2') + * Result is the same regardless of which party computes it: conv(a, B) == conv(b, A) + */ +export function getConversationKey(privateKeyHex: string, publicKeyHex: string): Buffer { + // ECDH: unhashed 32-byte x coordinate of the shared point + const shared = secp256k1.getSharedSecret(privateKeyHex, `02${publicKeyHex}`, true) + const sharedX = Buffer.from(shared).subarray(1) // strip 0x02 prefix + + return hkdfExtract(Buffer.from('nip44-v2'), sharedX) +} + +function getMessageKeys( + conversationKey: Buffer, + nonce: Buffer, +): { chachaKey: Buffer; chachaNonce: Buffer; hmacKey: Buffer } { + if (conversationKey.length !== 32) throw new Error('invalid conversation_key length') + if (nonce.length !== 32) throw new Error('invalid nonce length') + + const keys = hkdfExpand(conversationKey, nonce, 76) + return { + chachaKey: keys.subarray(0, 32), + chachaNonce: keys.subarray(32, 44), + hmacKey: keys.subarray(44, 76), + } +} + +function calcPaddedLen(unpaddedLen: number): number { + if (unpaddedLen <= 32) return 32 + const nextPower = 1 << (Math.floor(Math.log2(unpaddedLen - 1)) + 1) + const chunk = nextPower <= 256 ? 32 : nextPower / 8 + return chunk * (Math.floor((unpaddedLen - 1) / chunk) + 1) +} + +function pad(plaintext: string): Buffer { + const unpadded = Buffer.from(plaintext, 'utf8') + const unpaddedLen = unpadded.length + if (unpaddedLen < MIN_PLAINTEXT_SIZE || unpaddedLen > MAX_PLAINTEXT_SIZE) { + throw new Error('invalid plaintext length') + } + const prefix = Buffer.alloc(2) + prefix.writeUInt16BE(unpaddedLen, 0) + const suffix = Buffer.alloc(calcPaddedLen(unpaddedLen) - unpaddedLen) + return Buffer.concat([prefix, unpadded, suffix]) +} + +function unpad(padded: Buffer): string { + const unpaddedLen = padded.readUInt16BE(0) + const unpadded = padded.subarray(2, 2 + unpaddedLen) + if ( + unpaddedLen === 0 || + unpadded.length !== unpaddedLen || + padded.length !== 2 + calcPaddedLen(unpaddedLen) + ) { + throw new Error('invalid padding') + } + return unpadded.toString('utf8') +} + +/** + * Encrypt plaintext using NIP-44 v2. + * Output format: base64(0x02 || nonce[32] || ciphertext || mac[32]) + */ +export function nip44Encrypt( + plaintext: string, + conversationKey: Buffer, + nonce: Buffer = randomBytes(32), +): string { + const { chachaKey, chachaNonce, hmacKey } = getMessageKeys(conversationKey, nonce) + const padded = pad(plaintext) + + // ChaCha20: OpenSSL expects a 16-byte IV = [counter_le32=0][nonce_96bit] + const iv = Buffer.concat([Buffer.alloc(4), chachaNonce]) + const cipher = createCipheriv('chacha20', chachaKey, iv) + const ciphertext = Buffer.concat([cipher.update(padded), cipher.final()]) + + // MAC = HMAC-SHA256(hmacKey, nonce || ciphertext) + const mac = createHmac('sha256', hmacKey).update(nonce).update(ciphertext).digest() + + return Buffer.concat([Buffer.from([0x02]), nonce, ciphertext, mac]).toString('base64') +} + +/** + * Decrypt a NIP-44 v2 payload. + * Validates version byte, payload sizes, and MAC before decrypting. + */ +export function nip44Decrypt(payload: string, conversationKey: Buffer): string { + if (!payload || payload[0] === '#') throw new Error('unknown version') + if (payload.length < 132 || payload.length > 87472) throw new Error('invalid payload size') + + const data = Buffer.from(payload, 'base64') + if (data.length < 99 || data.length > 65603) throw new Error('invalid data size') + + const version = data[0] + if (version !== 2) throw new Error(`unknown version ${version}`) + + const nonce = data.subarray(1, 33) + const ciphertext = data.subarray(33, data.length - 32) + const mac = data.subarray(data.length - 32) + + const { chachaKey, chachaNonce, hmacKey } = getMessageKeys(conversationKey, nonce) + + const expectedMac = createHmac('sha256', hmacKey).update(nonce).update(ciphertext).digest() + if (!timingSafeEqual(expectedMac, mac)) throw new Error('invalid MAC') + + const iv = Buffer.concat([Buffer.alloc(4), chachaNonce]) + const decipher = createDecipheriv('chacha20', chachaKey, iv) + const padded = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + + return unpad(padded) +} + +/** + * Validate the structural format of a NIP-44 v2 payload without decrypting it. + * Returns an error string if invalid, or undefined if the format looks valid. + */ +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/ + +export function validateNip44Payload(payload: string): string | undefined { + if (!payload || payload[0] === '#') return 'unsupported encryption version' + if (payload.length < 132 || payload.length > 87472) return 'invalid payload size' + + if (payload.length % 4 !== 0 || !BASE64_RE.test(payload)) { + return 'payload is not valid base64' + } + + const data = Buffer.from(payload, 'base64') + + if (data.length < 99 || data.length > 65603) return 'invalid decoded payload size' + if (data[0] !== 2) return `unsupported encryption version ${data[0]}` + + return undefined +} diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 6895ec7c..30995f80 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -9,6 +9,7 @@ chai.use(sinonChai) chai.use(chaiAsPromised) import { EventLimits, Settings } from '../../../src/@types/settings' +import { identifyEvent, signEvent } from '../../../src/utils/event' import { IncomingEventMessage, MessageType } from '../../../src/@types/messages' import { Event } from '../../../src/@types/event' import { EventKinds } from '../../../src/constants/base' @@ -705,6 +706,56 @@ describe('EventMessageHandler', () => { event.sig = 'wrong' return expect((handler as any).isEventValid(event)).to.eventually.equal('invalid: event signature verification failed') }) + + describe('NIP-17 inner event blocking', () => { + // Use a known private key to generate valid events for kinds 13, 14, 15. + // The private key is the smallest valid secp256k1 scalar (value = 1). + const PRIVKEY = '0000000000000000000000000000000000000000000000000000000000000001' + + async function makeValidEvent(kind: EventKinds): Promise { + const unsigned = await identifyEvent({ + pubkey: '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + created_at: 1700000000, + kind, + tags: [], + content: '', + }) + return signEvent(PRIVKEY)(unsigned) + } + + it('blocks kind 13 (Seal) with a clear rejection message', async () => { + const sealEvent = await makeValidEvent(EventKinds.SEAL) + const reason = await (handler as any).isEventValid(sealEvent) + expect(reason).to.include('blocked') + expect(reason).to.include('13') + }) + + it('blocks kind 14 (Direct Message) with a clear rejection message', async () => { + const dmEvent = await makeValidEvent(EventKinds.DIRECT_MESSAGE) + const reason = await (handler as any).isEventValid(dmEvent) + expect(reason).to.include('blocked') + expect(reason).to.include('14') + }) + + it('blocks kind 15 (File Message) with a clear rejection message', async () => { + const fileEvent = await makeValidEvent(EventKinds.FILE_MESSAGE) + const reason = await (handler as any).isEventValid(fileEvent) + expect(reason).to.include('blocked') + expect(reason).to.include('15') + }) + + it('does not block a regular kind 1 event', async () => { + const textNote = await makeValidEvent(EventKinds.TEXT_NOTE) + const reason = await (handler as any).isEventValid(textNote) + expect(reason).to.be.undefined + }) + + it('does not block a kind 1059 (Gift Wrap) event', async () => { + const giftWrap = await makeValidEvent(EventKinds.GIFT_WRAP) + const reason = await (handler as any).isEventValid(giftWrap) + expect(reason).to.be.undefined + }) + }) }) describe('isRateLimited', () => { diff --git a/test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts b/test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts new file mode 100644 index 00000000..2b5d3bbe --- /dev/null +++ b/test/unit/handlers/event-strategies/gift-wrap-event-strategy.spec.ts @@ -0,0 +1,196 @@ +import * as secp256k1 from '@noble/secp256k1' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { getConversationKey, nip44Encrypt } from '../../../../src/utils/nip44' +import { DatabaseClient } from '../../../../src/@types/base' +import { Event } from '../../../../src/@types/event' +import { EventKinds } from '../../../../src/constants/base' +import { EventRepository } from '../../../../src/repositories/event-repository' +import { GiftWrapEventStrategy } from '../../../../src/handlers/event-strategies/gift-wrap-event-strategy' +import { IEventRepository } from '../../../../src/@types/repositories' +import { IEventStrategy } from '../../../../src/@types/message-handlers' +import { IWebSocketAdapter } from '../../../../src/@types/adapters' +import { MessageType } from '../../../../src/@types/messages' +import { WebSocketAdapterEvent } from '../../../../src/constants/adapter' + +const { expect } = chai + +// Generate a valid NIP-44 v2 payload for use as gift wrap content +function makeValidPayload(): string { + const sec1 = '0000000000000000000000000000000000000000000000000000000000000001' + const sec2 = '0000000000000000000000000000000000000000000000000000000000000002' + const pub2 = Buffer.from(secp256k1.getPublicKey(sec2, true)).subarray(1).toString('hex') + const convKey = getConversationKey(sec1, pub2) + return nip44Encrypt('{"kind":13,"content":"sealed"}', convKey) +} + +describe('GiftWrapEventStrategy', () => { + const recipientPubkey = 'a'.repeat(64) + + let validPayload: string + let event: Event + let webSocket: IWebSocketAdapter + let eventRepository: IEventRepository + let webSocketEmitStub: Sinon.SinonStub + let eventRepositoryCreateStub: Sinon.SinonStub + let strategy: IEventStrategy> + let sandbox: Sinon.SinonSandbox + + before(() => { + validPayload = makeValidPayload() + }) + + beforeEach(() => { + sandbox = Sinon.createSandbox() + + eventRepositoryCreateStub = sandbox.stub(EventRepository.prototype, 'create') + + webSocketEmitStub = sandbox.stub() + webSocket = { emit: webSocketEmitStub } as any + + const masterClient: DatabaseClient = {} as any + const readReplicaClient: DatabaseClient = {} as any + eventRepository = new EventRepository(masterClient, readReplicaClient) + + event = { + id: 'gift-wrap-id', + pubkey: 'b'.repeat(64), // ephemeral key — random per NIP-17 + created_at: 1700000000, + kind: EventKinds.GIFT_WRAP, + tags: [['p', recipientPubkey]], + content: validPayload, + sig: 'c'.repeat(128), + } as any + + strategy = new GiftWrapEventStrategy(webSocket, eventRepository) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('execute', () => { + describe('valid gift wrap', () => { + it('creates the event in the repository', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnceWithExactly(event) + }) + + it('sends OK and broadcasts when the event is new', async () => { + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledTwice + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, true, ''], + ) + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Broadcast, + event, + ) + }) + + it('sends OK with duplicate marker and does not broadcast when event already exists', async () => { + eventRepositoryCreateStub.resolves(0) + + await strategy.execute(event) + + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, true, 'duplicate:'], + ) + }) + }) + + describe('invalid gift wrap — p tag', () => { + it('rejects when the p tag is missing', async () => { + event.tags = [] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, false, Sinon.match(/invalid:.*p tag/)], + ) + }) + + it('rejects when there are multiple p tags', async () => { + event.tags = [ + ['p', 'a'.repeat(64)], + ['p', 'b'.repeat(64)], + ] + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, false, Sinon.match(/invalid:.*exactly one p tag/)], + ) + }) + + it('accepts p tag with an optional relay hint', async () => { + event.tags = [['p', recipientPubkey, 'wss://inbox.example.com']] + eventRepositoryCreateStub.resolves(1) + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).to.have.been.calledOnce + expect(webSocketEmitStub).to.have.been.calledWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, true, ''], + ) + }) + }) + + describe('invalid gift wrap — content format', () => { + it('rejects when content is empty', async () => { + event.content = '' + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, false, Sinon.match(/invalid:.*NIP-44/)], + ) + }) + + it('rejects when content is plain text instead of a NIP-44 payload', async () => { + event.content = 'this is not encrypted' + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, false, Sinon.match(/invalid:.*NIP-44/)], + ) + }) + + it('rejects when content signals an unsupported version (#)', async () => { + event.content = '#future-version-payload' + + await strategy.execute(event) + + expect(eventRepositoryCreateStub).not.to.have.been.called + expect(webSocketEmitStub).to.have.been.calledOnceWithExactly( + WebSocketAdapterEvent.Message, + [MessageType.OK, event.id, false, Sinon.match(/invalid:.*NIP-44/)], + ) + }) + }) + }) +}) diff --git a/test/unit/utils/event.spec.ts b/test/unit/utils/event.spec.ts index b24c7f0f..a307bb93 100644 --- a/test/unit/utils/event.spec.ts +++ b/test/unit/utils/event.spec.ts @@ -3,14 +3,18 @@ import { CanonicalEvent, Event } from '../../../src/@types/event' import { getEventExpiration, isDeleteEvent, + isDirectMessageEvent, isEphemeralEvent, isEventIdValid, isEventMatchingFilter, isEventSignatureValid, isExpiredEvent, + isFileMessageEvent, + isGiftWrapEvent, isParameterizedReplaceableEvent, isReplaceableEvent, isRequestToVanishEvent, + isSealEvent, serializeEvent, } from '../../../src/utils/event' import { expect } from 'chai' @@ -345,6 +349,60 @@ describe('NIP-16', () => { }) }) +describe('NIP-17', () => { + describe('isGiftWrapEvent', () => { + it('returns true for kind 1059', () => { + expect(isGiftWrapEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.true + }) + + it('returns false for any other kind', () => { + expect(isGiftWrapEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + expect(isGiftWrapEvent({ kind: EventKinds.SEAL } as any)).to.be.false + expect(isGiftWrapEvent({ kind: EventKinds.DIRECT_MESSAGE } as any)).to.be.false + }) + }) + + describe('isSealEvent', () => { + it('returns true for kind 13', () => { + expect(isSealEvent({ kind: EventKinds.SEAL } as any)).to.be.true + }) + + it('returns false for any other kind', () => { + expect(isSealEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + expect(isSealEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + expect(isSealEvent({ kind: EventKinds.DIRECT_MESSAGE } as any)).to.be.false + }) + }) + + describe('isDirectMessageEvent', () => { + it('returns true for kind 14', () => { + expect(isDirectMessageEvent({ kind: EventKinds.DIRECT_MESSAGE } as any)).to.be.true + }) + + it('returns false for kind 4 (legacy NIP-04 DM)', () => { + expect(isDirectMessageEvent({ kind: EventKinds.ENCRYPTED_DIRECT_MESSAGE } as any)).to.be.false + }) + + it('returns false for any other kind', () => { + expect(isDirectMessageEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + expect(isDirectMessageEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + expect(isDirectMessageEvent({ kind: EventKinds.SEAL } as any)).to.be.false + }) + }) + + describe('isFileMessageEvent', () => { + it('returns true for kind 15', () => { + expect(isFileMessageEvent({ kind: EventKinds.FILE_MESSAGE } as any)).to.be.true + }) + + it('returns false for any other kind', () => { + expect(isFileMessageEvent({ kind: EventKinds.TEXT_NOTE } as any)).to.be.false + expect(isFileMessageEvent({ kind: EventKinds.DIRECT_MESSAGE } as any)).to.be.false + expect(isFileMessageEvent({ kind: EventKinds.GIFT_WRAP } as any)).to.be.false + }) + }) +}) + // describe('NIP-27', () => { // describe('isEventMatchingFilter', () => { // describe('#m filter', () => { diff --git a/test/unit/utils/nip44.spec.ts b/test/unit/utils/nip44.spec.ts new file mode 100644 index 00000000..86dfc916 --- /dev/null +++ b/test/unit/utils/nip44.spec.ts @@ -0,0 +1,250 @@ +import * as secp256k1 from '@noble/secp256k1' +import { expect } from 'chai' + +import { + getConversationKey, + nip44Decrypt, + nip44Encrypt, + validateNip44Payload, +} from '../../../src/utils/nip44' + +// --------------------------------------------------------------------------- +// Helpers — compute pub from sec using the same library the relay uses +// --------------------------------------------------------------------------- + +function pubkeyFromPrivkey(secHex: string): string { + return Buffer.from(secp256k1.getPublicKey(secHex, true)).subarray(1).toString('hex') +} + +// --------------------------------------------------------------------------- +// Published test vector from the NIP-44 spec +// sec1: 000...001 sec2: 000...002 +// --------------------------------------------------------------------------- + +const SEC1 = '0000000000000000000000000000000000000000000000000000000000000001' +const SEC2 = '0000000000000000000000000000000000000000000000000000000000000002' +const KNOWN_CONVERSATION_KEY = 'c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d' +const KNOWN_NONCE = '0000000000000000000000000000000000000000000000000000000000000001' +const KNOWN_PLAINTEXT = 'a' +const KNOWN_PAYLOAD = + 'AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb' + +// --------------------------------------------------------------------------- + +describe('NIP-44', () => { + describe('getConversationKey', () => { + it('derives the correct conversation key from sec1 and pub2', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const key = getConversationKey(SEC1, pub2) + expect(key.toString('hex')).to.equal(KNOWN_CONVERSATION_KEY) + }) + + it('is symmetric: conv(a, B) == conv(b, A)', () => { + const pub1 = pubkeyFromPrivkey(SEC1) + const pub2 = pubkeyFromPrivkey(SEC2) + const keyAB = getConversationKey(SEC1, pub2) + const keyBA = getConversationKey(SEC2, pub1) + expect(keyAB.toString('hex')).to.equal(keyBA.toString('hex')) + }) + + it('produces different keys for different key pairs', () => { + const sec3 = '0000000000000000000000000000000000000000000000000000000000000003' + const pub2 = pubkeyFromPrivkey(SEC2) + const pub3 = pubkeyFromPrivkey(sec3) + const key12 = getConversationKey(SEC1, pub2) + const key13 = getConversationKey(SEC1, pub3) + expect(key12.toString('hex')).to.not.equal(key13.toString('hex')) + }) + }) + + describe('nip44Encrypt', () => { + it('produces the canonical payload from the NIP-44 spec test vector', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + const nonce = Buffer.from(KNOWN_NONCE, 'hex') + + const payload = nip44Encrypt(KNOWN_PLAINTEXT, conversationKey, nonce) + expect(payload).to.equal(KNOWN_PAYLOAD) + }) + + it('produces a valid base64 string starting with version byte 0x02', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + const payload = nip44Encrypt('hello', conversationKey) + const decoded = Buffer.from(payload, 'base64') + + expect(decoded[0]).to.equal(2) // version byte + expect(payload.length).to.be.within(132, 87472) + }) + + it('produces different ciphertexts for the same plaintext (random nonce)', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + const payload1 = nip44Encrypt('same message', conversationKey) + const payload2 = nip44Encrypt('same message', conversationKey) + + expect(payload1).to.not.equal(payload2) + }) + + it('throws for empty plaintext', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + expect(() => nip44Encrypt('', conversationKey)).to.throw('invalid plaintext length') + }) + + it('throws for plaintext exceeding 65535 bytes', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + expect(() => nip44Encrypt('x'.repeat(65536), conversationKey)).to.throw('invalid plaintext length') + }) + }) + + describe('nip44Decrypt', () => { + it('decrypts the canonical NIP-44 spec test vector', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + const plaintext = nip44Decrypt(KNOWN_PAYLOAD, conversationKey) + expect(plaintext).to.equal(KNOWN_PLAINTEXT) + }) + + it('round-trips any plaintext through encrypt then decrypt', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + const original = 'Hola, que tal? 🌍' + + const payload = nip44Encrypt(original, conversationKey) + const recovered = nip44Decrypt(payload, conversationKey) + + expect(recovered).to.equal(original) + }) + + it('works with the symmetric key (recipient decrypts sender message)', () => { + const pub1 = pubkeyFromPrivkey(SEC1) + const pub2 = pubkeyFromPrivkey(SEC2) + + const senderKey = getConversationKey(SEC1, pub2) + const recipientKey = getConversationKey(SEC2, pub1) + + const payload = nip44Encrypt('secret message', senderKey) + const plaintext = nip44Decrypt(payload, recipientKey) + + expect(plaintext).to.equal('secret message') + }) + + it('throws when MAC is tampered', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + const payload = nip44Encrypt('tamper me', conversationKey) + + // Flip the last character of the base64 payload to corrupt the MAC + const tampered = payload.slice(0, -4) + 'AAAA' + + expect(() => nip44Decrypt(tampered, conversationKey)).to.throw() + }) + + it('throws for payload starting with # (unsupported future version)', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + expect(() => nip44Decrypt('#not-base64', conversationKey)).to.throw('unknown version') + }) + + it('throws for payload that is too short', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + + expect(() => nip44Decrypt('dG9vc2hvcnQ=', conversationKey)).to.throw('invalid payload size') + }) + + it('throws for wrong conversation key', () => { + const sec3 = '0000000000000000000000000000000000000000000000000000000000000003' + const pub2 = pubkeyFromPrivkey(SEC2) + const pub3 = pubkeyFromPrivkey(sec3) + + const senderKey = getConversationKey(SEC1, pub2) + const wrongKey = getConversationKey(SEC1, pub3) + + const payload = nip44Encrypt('private', senderKey) + + expect(() => nip44Decrypt(payload, wrongKey)).to.throw() + }) + }) + + describe('validateNip44Payload', () => { + it('returns undefined for a valid NIP-44 v2 payload', () => { + expect(validateNip44Payload(KNOWN_PAYLOAD)).to.be.undefined + }) + + it('returns undefined for a freshly encrypted payload', () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + const payload = nip44Encrypt('hello', conversationKey) + + expect(validateNip44Payload(payload)).to.be.undefined + }) + + it('returns error string for payload starting with #', () => { + expect(validateNip44Payload('#unsupported')).to.be.a('string') + }) + + it('returns error string for empty string', () => { + expect(validateNip44Payload('')).to.be.a('string') + }) + + it('returns error string for payload that is too short', () => { + expect(validateNip44Payload('dG9vc2hvcnQ=')).to.be.a('string') + }) + + it('returns error string for payload that is too long', () => { + expect(validateNip44Payload('A'.repeat(87473))).to.be.a('string') + }) + + it('returns error string when version byte is not 0x02', () => { + // Build a fake payload: version=0x01 + 32-byte nonce + 34-byte ciphertext + 32-byte mac = 99 bytes + const fakeData = Buffer.alloc(99) + fakeData[0] = 0x01 // wrong version + const payload = fakeData.toString('base64') + + expect(validateNip44Payload(payload)).to.include('unsupported encryption version') + }) + }) + + describe('padding length (calc_padded_len)', () => { + // Test via the encrypt/decrypt round-trip: the padded buffer length is observable + // from the output size. Direct cases from the NIP-44 spec. + + const cases: [number, number][] = [ + [1, 32], + [32, 32], + [33, 64], + [64, 64], + [65, 96], + [100, 128], + [256, 256], + [257, 320], + [512, 512], + [1024, 1024], + ] + + for (const [unpaddedLen, expectedPaddedLen] of cases) { + it(`pads ${unpaddedLen} bytes to ${expectedPaddedLen} bytes`, () => { + const pub2 = pubkeyFromPrivkey(SEC2) + const conversationKey = getConversationKey(SEC1, pub2) + const plaintext = 'a'.repeat(unpaddedLen) + + const payload = nip44Encrypt(plaintext, conversationKey) + const decoded = Buffer.from(payload, 'base64') + + // Layout: 1 (version) + 32 (nonce) + paddedLen + 2 (length prefix) + 32 (mac) + const paddedWithPrefix = decoded.length - 1 - 32 - 32 + // paddedWithPrefix = 2 (u16 prefix) + expectedPaddedLen + expect(paddedWithPrefix).to.equal(expectedPaddedLen + 2) + }) + } + }) +})