diff --git a/.changeset/nip70-reject-protected-events.md b/.changeset/nip70-reject-protected-events.md new file mode 100644 index 00000000..a5247b13 --- /dev/null +++ b/.changeset/nip70-reject-protected-events.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: reject NIP-70 protected events and reposts embedding them diff --git a/src/constants/base.ts b/src/constants/base.ts index d5b5f526..84625c8f 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -7,6 +7,8 @@ export enum EventKinds { DELETE = 5, REPOST = 6, REACTION = 7, + // NIP-18: Reposts + GENERIC_REPOST = 16, // NIP-17: Private Direct Messages SEAL = 13, DIRECT_MESSAGE = 14, diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index c58efeb9..67b6b146 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -1,4 +1,6 @@ import { ContextMetadataKey, EventExpirationTimeMetadataKey, EventKinds } from '../constants/base' +import { attemptValidation } from '../utils/validation' +import { eventSchema } from '../schemas/event-schema' import { DEFAULT_NIP05_VERIFY_EXPIRATION_MS, extractNip05FromEvent, @@ -21,6 +23,7 @@ import { isEventSignatureValid, isExpiredEvent, isFileMessageEvent, + isProtectedEvent, isRequestToVanishEvent, isSealEvent, isWelcomeRumorEvent, @@ -88,6 +91,13 @@ export class EventMessageHandler implements IMessageHandler { return } + reason = await this.isProtectedEventBlocked(event) + if (reason) { + logger('event %s rejected: %s', event.id, reason) + this.webSocket.emit(WebSocketAdapterEvent.Message, createCommandResult(event.id, false, reason)) + return + } + reason = await this.isBlockedByRequestToVanish(event) if (reason) { logger('event %s rejected: %s', event.id, reason) @@ -224,6 +234,38 @@ export class EventMessageHandler implements IMessageHandler { } } + protected async isProtectedEventBlocked(event: Event): Promise { + if (isProtectedEvent(event)) { + if (!this.webSocket.getAuthenticatedPubkeys().has(event.pubkey)) { + return 'auth-required: this event may only be published by its author' + } + } + + const checkEmbedded = async (evt: Event, depth = 0): Promise => { + if (depth > 10) return false // Prevent infinite loops or excessive recursion + if ((evt.kind === EventKinds.REPOST || evt.kind === EventKinds.GENERIC_REPOST) && evt.content.length > 0) { + try { + const embedded = attemptValidation(eventSchema)(JSON.parse(evt.content)) as Event + if (!(await isEventIdValid(embedded)) || !(await isEventSignatureValid(embedded))) { + return false + } + if (isProtectedEvent(embedded)) { + return true + } + return await checkEmbedded(embedded, depth + 1) + } catch (error) { + logger.warn('event %s repost embedded event validation failed: %o', evt.id, error) + return false + } + } + return false + } + + if (await checkEmbedded(event)) { + return 'blocked: reposts must not embed protected events' + } + } + protected async isEventValid(event: Event): Promise { if (!(await isEventIdValid(event))) { return 'invalid: event id does not match' diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 57ee66a9..14c894da 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -2120,4 +2120,164 @@ describe('EventMessageHandler', () => { expect(nip05VerificationRepository.upsert).to.have.been.calledOnce }) }) + + describe('isProtectedEventBlocked', () => { + const PRIVKEY = '0000000000000000000000000000000000000000000000000000000000000001' + + beforeEach(() => { + handler = new EventMessageHandler( + { getAuthenticatedPubkeys: () => new Set() } as any, + () => null, + {} as any, + userRepository, + () => + ({ + info: { relay_url: 'relay_url' }, + }) as any, + {} as any, + { hasKey: async () => false, setKey: async () => true } as any, + () => ({ hit: async () => false }), + ) + }) + + const createValidEmbeddedEvent = async (tags: string[][]): Promise => { + const unsigned = await identifyEvent({ + pubkey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'.slice(2), + created_at: 1000, + kind: 1, + tags: tags as any, + content: 'test content', + }) + return signEvent(PRIVKEY)(unsigned) + } + + it('returns reason if event has a protected tag and user is not authenticated', async () => { + event.tags = [['-']] as any + expect(await (handler as any).isProtectedEventBlocked(event)).to.equal( + 'auth-required: this event may only be published by its author', + ) + }) + + it('returns undefined if event has a protected tag and user is authenticated', async () => { + event.tags = [['-']] as any + handler = new EventMessageHandler( + { getAuthenticatedPubkeys: () => new Set([event.pubkey]) } as any, + () => null, + {} as any, + userRepository, + () => ({ info: { relay_url: 'relay_url' } }) as any, + {} as any, + { hasKey: async () => false, setKey: async () => true } as any, + () => ({ hit: async () => false }), + ) + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns undefined if event has no protected tag', async () => { + event.tags = [['e', 'abc']] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns undefined if event has no tags', async () => { + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns reason if kind 6 repost embeds a valid protected event', async () => { + const embedded = await createValidEmbeddedEvent([['-']]) + event.kind = EventKinds.REPOST + event.content = JSON.stringify(embedded) + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.equal( + 'blocked: reposts must not embed protected events', + ) + }) + + it('returns reason if kind 6 repost embeds a repost that embeds a valid protected event (nested)', async () => { + const protectedEvent = await createValidEmbeddedEvent([['-']]) + const middleRepost = await identifyEvent({ + pubkey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'.slice(2), + created_at: 1000, + kind: EventKinds.GENERIC_REPOST, + tags: [], + content: JSON.stringify(protectedEvent), + }) + const signedMiddleRepost = await signEvent(PRIVKEY)(middleRepost) + + event.kind = EventKinds.REPOST + event.content = JSON.stringify(signedMiddleRepost) + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.equal( + 'blocked: reposts must not embed protected events', + ) + }) + + it('returns undefined if kind 6 repost embeds a valid non-protected event', async () => { + const embedded = await createValidEmbeddedEvent([]) + event.kind = EventKinds.REPOST + event.content = JSON.stringify(embedded) + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns undefined if embedded event has invalid id/sig (forged protected tag)', async () => { + event.kind = EventKinds.REPOST + event.content = JSON.stringify({ + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + kind: 1, + tags: [['-']], + content: 'secret', + sig: 'c'.repeat(128), + created_at: 1000, + }) + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns undefined if kind 6 repost has empty content', async () => { + event.kind = EventKinds.REPOST + event.content = '' + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns undefined if kind 6 repost has invalid JSON content', async () => { + event.kind = EventKinds.REPOST + event.content = 'not json' + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('returns undefined for non-repost event kinds with JSON content', async () => { + event.kind = EventKinds.TEXT_NOTE + event.content = JSON.stringify({ tags: [['-']] }) + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + + it('rejects on the outer protected tag before checking embedded repost content', async () => { + event.kind = EventKinds.REPOST + event.content = JSON.stringify({ + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + kind: 1, + tags: [['-']], + content: 'secret', + sig: 'c'.repeat(128), + created_at: 1000, + }) + event.tags = [['-']] as any + expect(await (handler as any).isProtectedEventBlocked(event)).to.equal( + 'auth-required: this event may only be published by its author', + ) + }) + + it('returns undefined if kind 6 repost has non-array embedded tags', async () => { + event.kind = EventKinds.REPOST + event.content = JSON.stringify({ tags: 'not-an-array' }) + event.tags = [] + expect(await (handler as any).isProtectedEventBlocked(event)).to.be.undefined + }) + }) })