Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
12,
15,
16,
17,
20,
22,
28,
33,
40
40,
44
],
"supportedNipExtensions": [
"11a"
Expand Down
6 changes: 6 additions & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/factories/event-strategy-factory.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)) {
Expand All @@ -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)
}
10 changes: 10 additions & 0 deletions src/handlers/event-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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`
}
Comment thread
CKodidela marked this conversation as resolved.
}

protected async isBlockedByRequestToVanish(event: Event): Promise<string | undefined> {
Expand Down
64 changes: 64 additions & 0 deletions src/handlers/event-strategies/gift-wrap-event-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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<Event, Promise<void>> {
public constructor(
private readonly webSocket: IWebSocketAdapter,
private readonly eventRepository: IEventRepository,
) {}

public async execute(event: Event): Promise<void> {
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 per recipient'
}

Comment thread
CKodidela marked this conversation as resolved.
Outdated
// 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
}
}
46 changes: 18 additions & 28 deletions src/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<Event> => {
return new Promise((resolve, reject) => {
if (!cluster.isWorker || typeof process.send === 'undefined') {
Expand Down Expand Up @@ -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
}
Loading
Loading