From 9823afe9fff0d133cefb1216909d629070592ff3 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 1/9] feat(nip42): add restrictedReads settings and defaults --- resources/default-settings.yaml | 8 ++++++++ src/@types/settings.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 300e2f38..96003ad5 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -60,6 +60,14 @@ nip05: domainWhitelist: [] # Block authors with NIP-05 at these domains domainBlacklist: [] +nip42: + # Only deliver these kinds to clients authenticated (NIP-42) as the event's + # author or a p-tagged recipient. Applies to REQ, live events and COUNT. + restrictedReads: + enabled: false + kinds: + - 4 # NIP-04 encrypted direct messages + - 1059 # NIP-59 gift wraps (NIP-17 private DMs, Marmot welcomes) nip45: enabled: true nip50: diff --git a/src/@types/settings.ts b/src/@types/settings.ts index ce111cfe..830a5dbe 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -303,6 +303,16 @@ export interface WoTSettings { refreshIntervalHours: number } +export interface Nip42RestrictedReads { + enabled: boolean + // Restricted kinds/ranges. Defaults to [4, 1059] when unset. + kinds?: (EventKinds | EventKindsRange)[] +} + +export interface Nip42Settings { + restrictedReads?: Nip42RestrictedReads +} + export interface Nip43Settings { enabled: boolean inviteCodeExpiry?: number @@ -321,6 +331,7 @@ export interface Settings { limits?: Limits mirroring?: Mirroring nip05?: Nip05Settings + nip42?: Nip42Settings nip43?: Nip43Settings nip45?: Nip45Settings nip50?: Nip50Settings From a3fcde7e44f094a70ba08027dbe4473351892b57 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 2/9] feat(nip42): add read authorization helpers --- src/utils/nip42.ts | 112 ++++++++++++++++++++ test/unit/utils/nip42.spec.ts | 185 ++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 src/utils/nip42.ts create mode 100644 test/unit/utils/nip42.spec.ts diff --git a/src/utils/nip42.ts b/src/utils/nip42.ts new file mode 100644 index 00000000..1ea0f86e --- /dev/null +++ b/src/utils/nip42.ts @@ -0,0 +1,112 @@ +import { EventKinds, EventTags } from '../constants/base' +import { EventKindsRange, Settings } from '../@types/settings' +import { Event } from '../@types/event' +import { isEventKindOrRangeMatch } from './event' +import { Pubkey } from '../@types/base' +import { SubscriptionFilter } from '../@types/subscription' + +// NIP-42: restricted kinds are only readable by the author or a p-tagged recipient. + +export const DEFAULT_RESTRICTED_READ_KINDS: (EventKinds | EventKindsRange)[] = [ + EventKinds.ENCRYPTED_DIRECT_MESSAGE, + EventKinds.GIFT_WRAP, +] + +export const getRestrictedReadKinds = (settings: Settings | undefined): (EventKinds | EventKindsRange)[] => { + const restrictedReads = settings?.nip42?.restrictedReads + if (!restrictedReads?.enabled) { + return [] + } + + return Array.isArray(restrictedReads.kinds) ? restrictedReads.kinds : DEFAULT_RESTRICTED_READ_KINDS +} + +const isKindRestricted = (restrictedKinds: (EventKinds | EventKindsRange)[], kind: number): boolean => + restrictedKinds.some(isEventKindOrRangeMatch({ kind } as Event)) + +export const isClientAuthorizedToReadEvent = (event: Event, authenticatedPubkeys: ReadonlySet): boolean => { + if (!authenticatedPubkeys.size) { + return false + } + + if (authenticatedPubkeys.has(event.pubkey)) { + return true + } + + return event.tags.some( + (tag) => tag.length >= 2 && tag[0] === EventTags.Pubkey && authenticatedPubkeys.has(tag[1]), + ) +} + +// getAuthenticatedPubkeys is only read for restricted events, so the guard is free when disabled. +export const createReadAuthorizationGuard = ( + settings: Settings | undefined, + getAuthenticatedPubkeys: () => ReadonlySet, +): ((event: Event) => boolean) => { + const restrictedKinds = getRestrictedReadKinds(settings) + if (!restrictedKinds.length) { + return () => true + } + + return (event: Event) => { + if (!isKindRestricted(restrictedKinds, event.kind)) { + return true + } + + return isClientAuthorizedToReadEvent(event, getAuthenticatedPubkeys()) + } +} + +const isFullyRestrictedFilter = + (restrictedKinds: (EventKinds | EventKindsRange)[]) => + (filter: SubscriptionFilter): boolean => + Array.isArray(filter.kinds) && + filter.kinds.length > 0 && + filter.kinds.every((kind) => isKindRestricted(restrictedKinds, kind)) + +// A sub that only asks for restricted kinds can never return anything to an +// unauthenticated client, so we close it instead of serving an empty stream. +export const isSubscriptionAuthRequired = ( + settings: Settings | undefined, + filters: SubscriptionFilter[], + getAuthenticatedPubkeys: () => ReadonlySet, +): boolean => { + const restrictedKinds = getRestrictedReadKinds(settings) + if (!restrictedKinds.length) { + return false + } + + if (!filters.length || !filters.every(isFullyRestrictedFilter(restrictedKinds))) { + return false + } + + return getAuthenticatedPubkeys().size === 0 +} + +// COUNT can't be filtered per event, so a restricted-kind filter must be +// scoped to the client's own pubkeys via authors/#p. +export const isCountAuthorized = ( + settings: Settings | undefined, + filters: SubscriptionFilter[], + getAuthenticatedPubkeys: () => ReadonlySet, +): boolean => { + const restrictedKinds = getRestrictedReadKinds(settings) + if (!restrictedKinds.length) { + return true + } + + const restrictedFilters = filters.filter( + (filter) => Array.isArray(filter.kinds) && filter.kinds.some((kind) => isKindRestricted(restrictedKinds, kind)), + ) + if (!restrictedFilters.length) { + return true + } + + const authenticatedPubkeys = getAuthenticatedPubkeys() + const isScopedToClient = (values?: Pubkey[]) => + Array.isArray(values) && values.length > 0 && values.every((value) => authenticatedPubkeys.has(value)) + + return restrictedFilters.every( + (filter) => isScopedToClient(filter.authors) || isScopedToClient(filter['#p']), + ) +} diff --git a/test/unit/utils/nip42.spec.ts b/test/unit/utils/nip42.spec.ts new file mode 100644 index 00000000..440c5d3d --- /dev/null +++ b/test/unit/utils/nip42.spec.ts @@ -0,0 +1,185 @@ +import { expect } from 'chai' + +import { + createReadAuthorizationGuard, + DEFAULT_RESTRICTED_READ_KINDS, + getRestrictedReadKinds, + isClientAuthorizedToReadEvent, + isCountAuthorized, + isSubscriptionAuthRequired, +} from '../../../src/utils/nip42' +import { Event } from '../../../src/@types/event' +import { Settings } from '../../../src/@types/settings' +import { SubscriptionFilter } from '../../../src/@types/subscription' + +const author = 'a'.repeat(64) +const recipient = 'b'.repeat(64) +const stranger = 'c'.repeat(64) + +const makeEvent = (kind: number, tags: string[][] = []): Event => + ({ + id: 'e'.repeat(64), + pubkey: author, + created_at: 1665546189, + kind, + tags, + content: '', + sig: 'f'.repeat(128), + }) as Event + +const enabledSettings = (kinds?: (number | [number, number])[]): Settings => + ({ + nip42: { + restrictedReads: { + enabled: true, + ...(kinds ? { kinds } : {}), + }, + }, + }) as unknown as Settings + +describe('nip42', () => { + describe('getRestrictedReadKinds', () => { + it('returns empty array when settings are undefined', () => { + expect(getRestrictedReadKinds(undefined)).to.deep.equal([]) + }) + + it('returns empty array when restrictedReads is disabled', () => { + const settings = { + nip42: { restrictedReads: { enabled: false, kinds: [4] } }, + } as unknown as Settings + + expect(getRestrictedReadKinds(settings)).to.deep.equal([]) + }) + + it('returns configured kinds when enabled', () => { + expect(getRestrictedReadKinds(enabledSettings([4, [1059, 1060]]))).to.deep.equal([4, [1059, 1060]]) + }) + + it('returns default kinds when enabled without explicit kinds', () => { + expect(getRestrictedReadKinds(enabledSettings())).to.deep.equal(DEFAULT_RESTRICTED_READ_KINDS) + }) + }) + + describe('isClientAuthorizedToReadEvent', () => { + it('returns false when no pubkeys are authenticated', () => { + expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set())).to.be.false + }) + + it('returns true for the event author', () => { + expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set([author]))).to.be.true + }) + + it('returns true for a p-tagged recipient', () => { + expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set([recipient]))).to.be.true + }) + + it('returns false for an unrelated authenticated pubkey', () => { + expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set([stranger]))).to.be.false + }) + }) + + describe('createReadAuthorizationGuard', () => { + it('allows everything when the feature is disabled', () => { + const guard = createReadAuthorizationGuard(undefined, () => { + throw new Error('must not be called when disabled') + }) + + expect(guard(makeEvent(4, [['p', recipient]]))).to.be.true + }) + + it('allows unrestricted kinds without consulting authentication', () => { + const guard = createReadAuthorizationGuard(enabledSettings(), () => { + throw new Error('must not be called for unrestricted kinds') + }) + + expect(guard(makeEvent(1))).to.be.true + }) + + it('blocks restricted kinds for unauthenticated clients', () => { + const guard = createReadAuthorizationGuard(enabledSettings(), () => new Set()) + + expect(guard(makeEvent(1059, [['p', recipient]]))).to.be.false + }) + + it('allows restricted kinds for the tagged recipient', () => { + const guard = createReadAuthorizationGuard(enabledSettings(), () => new Set([recipient])) + + expect(guard(makeEvent(1059, [['p', recipient]]))).to.be.true + }) + + it('supports kind ranges', () => { + const guard = createReadAuthorizationGuard(enabledSettings([[1050, 1100]]), () => new Set()) + + expect(guard(makeEvent(1059))).to.be.false + expect(guard(makeEvent(1101))).to.be.true + }) + }) + + describe('isSubscriptionAuthRequired', () => { + const unauthenticated = () => new Set() + + it('returns false when the feature is disabled', () => { + expect(isSubscriptionAuthRequired(undefined, [{ kinds: [4] }], unauthenticated)).to.be.false + }) + + it('returns true when every filter exclusively targets restricted kinds', () => { + const filters: SubscriptionFilter[] = [{ kinds: [4] }, { kinds: [1059], '#p': [recipient] }] + + expect(isSubscriptionAuthRequired(enabledSettings(), filters, unauthenticated)).to.be.true + }) + + it('returns false when any filter includes an unrestricted kind', () => { + const filters: SubscriptionFilter[] = [{ kinds: [1, 1059] }] + + expect(isSubscriptionAuthRequired(enabledSettings(), filters, unauthenticated)).to.be.false + }) + + it('returns false for filters without kinds', () => { + expect(isSubscriptionAuthRequired(enabledSettings(), [{}], unauthenticated)).to.be.false + }) + + it('returns false when the client is authenticated', () => { + const filters: SubscriptionFilter[] = [{ kinds: [4] }] + + expect(isSubscriptionAuthRequired(enabledSettings(), filters, () => new Set([recipient]))).to.be.false + }) + }) + + describe('isCountAuthorized', () => { + it('returns true when the feature is disabled', () => { + expect(isCountAuthorized(undefined, [{ kinds: [4] }], () => new Set())).to.be.true + }) + + it('returns true when no filter targets restricted kinds', () => { + expect(isCountAuthorized(enabledSettings(), [{ kinds: [1] }, {}], () => new Set())).to.be.true + }) + + it('returns false for unauthenticated restricted-kind counts', () => { + expect(isCountAuthorized(enabledSettings(), [{ kinds: [1059], '#p': [recipient] }], () => new Set())).to.be.false + }) + + it('returns false when scoped to somebody else', () => { + const filters: SubscriptionFilter[] = [{ kinds: [1059], '#p': [recipient] }] + + expect(isCountAuthorized(enabledSettings(), filters, () => new Set([stranger]))).to.be.false + }) + + it('returns true when #p is scoped to the authenticated client', () => { + const filters: SubscriptionFilter[] = [{ kinds: [1059], '#p': [recipient] }] + + expect(isCountAuthorized(enabledSettings(), filters, () => new Set([recipient]))).to.be.true + }) + + it('returns true when authors are scoped to the authenticated client', () => { + const filters: SubscriptionFilter[] = [{ kinds: [4], authors: [author] }] + + expect(isCountAuthorized(enabledSettings(), filters, () => new Set([author]))).to.be.true + }) + + it('returns false for unscoped restricted-kind counts even when authenticated', () => { + const filters: SubscriptionFilter[] = [{ kinds: [4] }] + + expect(isCountAuthorized(enabledSettings(), filters, () => new Set([author]))).to.be.false + }) + }) +}) From 82a5bb2d2078f7faf04b9b6a9463cb3d6c5d4a64 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 3/9] feat(nip42): filter restricted kinds in REQ and close unauth-only subs --- src/handlers/subscribe-message-handler.ts | 19 ++++ .../subscribe-message-handler.spec.ts | 105 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index e9fac71a..532e59c4 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -3,10 +3,12 @@ import { anyPass, equals, isNil, map, omit, propSatisfies, uniqWith } from 'ramd import { pipeline } from 'stream/promises' import { + createClosedMessage, createEndOfStoredEventsNoticeMessage, createNoticeMessage, createOutgoingEventMessage, } from '../utils/messages' +import { createReadAuthorizationGuard, isSubscriptionAuthRequired } from '../utils/nip42' import { IAbortable, IMessageHandler } from '../@types/message-handlers' import { isEventMatchingFilter, isExpiredEvent, toNostrEvent } from '../utils/event' import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream' @@ -51,6 +53,16 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return } + // NIP-42: close restricted-only subs from unauthenticated clients. + if (isSubscriptionAuthRequired(this.settings(), filters, () => this.webSocket.getAuthenticatedPubkeys())) { + logger('subscription %s with %o rejected: auth required', subscriptionId, filters) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createClosedMessage(subscriptionId, 'auth-required: authentication is required to request these event kinds'), + ) + return + } + this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, filters) await this.fetchAndSend(subscriptionId, filters) @@ -70,6 +82,12 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return true } + // NIP-42: drop restricted-kind events the client isn't authorized to read. + const isReadAuthorized = createReadAuthorizationGuard( + this.settings(), + () => this.webSocket.getAuthenticatedPubkeys(), + ) + const findEvents = this.eventRepository.findByFilters(filters).stream() // const abortableFindEvents = addAbortSignal(this.abortController.signal, findEvents) @@ -80,6 +98,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { streamFilter(propSatisfies(isNil, 'deleted_at')), streamMap(toNostrEvent), streamFilter(isTagUnexpired), + streamFilter(isReadAuthorized), streamFilter(isSubscribedToEvent), streamEach(sendEvent), streamEnd(sendEOSE), diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 35f1bb2b..bab45bdb 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -104,6 +104,47 @@ describe('SubscribeMessageHandler', () => { expect(webSocketOnSubscribeStub).to.have.been.calledOnceWith(subscriptionId) expect(fetchAndSendStub).to.have.been.calledOnceWithExactly(subscriptionId, filters) }) + + it('closes subscription with auth-required if unauthenticated client requests only restricted kinds', async () => { + canSubscribeStub.returns(undefined) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set()) + message = [MessageType.REQ, subscriptionId, { kinds: [4, 1059] }] as any + + await handler.handleMessage(message) + + expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly([ + 'CLOSED', + subscriptionId, + 'auth-required: authentication is required to request these event kinds', + ]) + expect(webSocketOnSubscribeStub).not.to.have.been.called + expect(fetchAndSendStub).not.to.have.been.called + }) + + it('accepts restricted-kind subscription if client is authenticated', async () => { + canSubscribeStub.returns(undefined) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set(['a'.repeat(64)])) + message = [MessageType.REQ, subscriptionId, { kinds: [1059] }] as any + + await handler.handleMessage(message) + + expect(webSocketOnSubscribeStub).to.have.been.calledOnceWith(subscriptionId) + expect(fetchAndSendStub).to.have.been.calledOnce + }) + + it('accepts mixed-kind subscription from unauthenticated client', async () => { + canSubscribeStub.returns(undefined) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set()) + message = [MessageType.REQ, subscriptionId, { kinds: [1, 1059] }] as any + + await handler.handleMessage(message) + + expect(webSocketOnSubscribeStub).to.have.been.calledOnceWith(subscriptionId) + expect(fetchAndSendStub).to.have.been.calledOnce + }) }) describe('#fetchAndSend', () => { @@ -210,6 +251,70 @@ describe('SubscribeMessageHandler', () => { expect(webSocketOnMessageStub).to.have.been.calledWithExactly(['EOSE', subscriptionId]) }) + it('does not send restricted-kind events to unauthenticated clients', async () => { + isClientSubscribedToEventStub.returns(always(true)) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set()) + + const restrictedEvent: Event = { + ...event, + kind: 4, + tags: [['p', 'f'.repeat(64)] as any], + } + + const promise = (handler as any).fetchAndSend(subscriptionId, filters) + + stream.write(toDbEvent(restrictedEvent)) + stream.end() + + await promise + + expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly(['EOSE', subscriptionId]) + }) + + it('sends restricted-kind events to the authenticated recipient', async () => { + const recipient = 'f'.repeat(64) + isClientSubscribedToEventStub.returns(always(true)) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set([recipient])) + + const restrictedEvent: Event = { + ...event, + kind: 1059, + tags: [['p', recipient] as any], + } + + const promise = (handler as any).fetchAndSend(subscriptionId, filters) + + stream.write(toDbEvent(restrictedEvent)) + stream.end() + + await promise + + expect(webSocketOnMessageStub).to.have.been.calledWithExactly(['EVENT', subscriptionId, restrictedEvent]) + }) + + it('sends restricted-kind events to the authenticated author', async () => { + isClientSubscribedToEventStub.returns(always(true)) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set([event.pubkey])) + + const restrictedEvent: Event = { + ...event, + kind: 4, + tags: [['p', 'f'.repeat(64)] as any], + } + + const promise = (handler as any).fetchAndSend(subscriptionId, filters) + + stream.write(toDbEvent(restrictedEvent)) + stream.end() + + await promise + + expect(webSocketOnMessageStub).to.have.been.calledWithExactly(['EVENT', subscriptionId, restrictedEvent]) + }) + it('sends EOSE', async () => { const promise = (handler as any).fetchAndSend(subscriptionId, filters) From fa855b4f55a04a0885d8e87104635d56d4c52201 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 4/9] feat(nip42): require auth for restricted-kind COUNT --- src/handlers/count-message-handler.ts | 6 +++ .../handlers/count-message-handler.spec.ts | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/handlers/count-message-handler.ts b/src/handlers/count-message-handler.ts index 74aa8ee8..fa99990c 100644 --- a/src/handlers/count-message-handler.ts +++ b/src/handlers/count-message-handler.ts @@ -9,6 +9,7 @@ import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { WebSocketAdapterEvent } from '../constants/adapter' import { createLogger } from '../factories/logger-factory' import { createClosedMessage, createCountResultMessage } from '../utils/messages' +import { isCountAuthorized } from '../utils/nip42' const debug = createLogger('count-message-handler') @@ -63,5 +64,10 @@ export class CountMessageHandler implements IMessageHandler { ) { return `Query ID too long: Query ID must be less than or equal to ${subscriptionLimits.maxSubscriptionIdLength}` } + + // NIP-42: restricted-kind counts must be scoped to the client's own pubkeys. + if (!isCountAuthorized(this.settings(), filters, () => this.webSocket.getAuthenticatedPubkeys())) { + return 'auth-required: authentication is required to count these event kinds' + } } } diff --git a/test/unit/handlers/count-message-handler.spec.ts b/test/unit/handlers/count-message-handler.spec.ts index 05befd01..1c60fe5f 100644 --- a/test/unit/handlers/count-message-handler.spec.ts +++ b/test/unit/handlers/count-message-handler.spec.ts @@ -130,6 +130,54 @@ describe('CountMessageHandler', () => { ]) }) + it('returns CLOSED with auth-required when counting restricted kinds unauthenticated', async () => { + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set()) + handler = new CountMessageHandler(webSocket, eventRepository, () => ({ + nip42: { restrictedReads: { enabled: true } }, + }) as Settings) + + const message = [MessageType.COUNT, 'q1', { kinds: [1059], '#p': ['f'.repeat(64)] }] as any + + await handler.handleMessage(message) + + expect(eventRepository.countByFilters).to.not.have.been.called + expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly([ + MessageType.CLOSED, + 'q1', + 'auth-required: authentication is required to count these event kinds', + ]) + }) + + it('returns CLOSED when counting restricted kinds scoped to another pubkey', async () => { + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set(['a'.repeat(64)])) + handler = new CountMessageHandler(webSocket, eventRepository, () => ({ + nip42: { restrictedReads: { enabled: true } }, + }) as Settings) + + const message = [MessageType.COUNT, 'q1', { kinds: [1059], '#p': ['f'.repeat(64)] }] as any + + await handler.handleMessage(message) + + expect(eventRepository.countByFilters).to.not.have.been.called + expect(webSocketOnMessageStub.firstCall.args[0][0]).to.equal(MessageType.CLOSED) + }) + + it('returns COUNT when restricted kinds are scoped to the authenticated client', async () => { + const pubkey = 'f'.repeat(64) + webSocket.getAuthenticatedPubkeys = sandbox.stub().returns(new Set([pubkey])) + handler = new CountMessageHandler(webSocket, eventRepository, () => ({ + nip42: { restrictedReads: { enabled: true } }, + }) as Settings) + + const filter = { kinds: [1059], '#p': [pubkey] } + const message = [MessageType.COUNT, 'q1', filter] as any + + await handler.handleMessage(message) + + expect(eventRepository.countByFilters).to.have.been.calledOnceWithExactly([filter]) + expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly([MessageType.COUNT, 'q1', { count: 7 }]) + }) + it('returns CLOSED when COUNT is disabled in settings', async () => { handler = new CountMessageHandler(webSocket, eventRepository, () => ({ nip45: { enabled: false } }) as Settings) From 6875441ac1862ca60e5d0ff95fdba49db38c245e Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 5/9] feat(nip42): gate live broadcasts on auth --- src/adapters/web-socket-adapter.ts | 7 ++ test/unit/adapters/web-socket-adapter.spec.ts | 88 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index c11be279..6cfec3a9 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -18,6 +18,7 @@ import { createLogger } from '../factories/logger-factory' import { recordWebsocketConnectionClosed, recordWebsocketConnectionOpened } from '../telemetry/event-metrics' import { Event } from '../@types/event' import { getRemoteAddress } from '../utils/http' +import { createReadAuthorizationGuard } from '../utils/nip42' import { IRateLimiter } from '../@types/utils' import { isEventMatchingFilter } from '../utils/event' import { messageSchema } from '../schemas/message-schema' @@ -120,6 +121,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } public onSendEvent(event: Event): void { + // NIP-42: don't broadcast restricted-kind events to unauthorized clients. + const isReadAuthorized = createReadAuthorizationGuard(this.settings(), () => this.authenticatedPubkeys) + if (!isReadAuthorized(event)) { + return + } + this.subscriptions.forEach((filters, subscriptionId) => { if (filters.map(isEventMatchingFilter).some((isMatch) => isMatch(event))) { logger('sending event to client %s: %o', this.clientId, event) diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index 9436e75d..c59e9f83 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -293,6 +293,94 @@ describe('WebSocketAdapter', () => { expect(client.send).not.to.have.been.called }) + + it('does not send restricted-kind event to unauthenticated client', () => { + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + client.readyState = WebSocket.OPEN + adapter.onSubscribed('sub-1', [{ kinds: [1059] }]) + + const event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + kind: 1059, + content: 'sealed', + created_at: 1000000, + sig: 'c'.repeat(128), + tags: [['p', 'd'.repeat(64)]], + } + + adapter.emit(WebSocketAdapterEvent.Event, event) + + expect(client.send).not.to.have.been.called + }) + + it('does not send restricted-kind event to a client authenticated as somebody else', () => { + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + client.readyState = WebSocket.OPEN + adapter.addAuthenticatedPubkey('e'.repeat(64)) + adapter.onSubscribed('sub-1', [{ kinds: [1059] }]) + + const event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + kind: 1059, + content: 'sealed', + created_at: 1000000, + sig: 'c'.repeat(128), + tags: [['p', 'd'.repeat(64)]], + } + + adapter.emit(WebSocketAdapterEvent.Event, event) + + expect(client.send).not.to.have.been.called + }) + + it('sends restricted-kind event to the authenticated recipient', () => { + const recipient = 'd'.repeat(64) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + client.readyState = WebSocket.OPEN + adapter.addAuthenticatedPubkey(recipient) + adapter.onSubscribed('sub-1', [{ kinds: [1059] }]) + + const event = { + id: 'a'.repeat(64), + pubkey: 'b'.repeat(64), + kind: 1059, + content: 'sealed', + created_at: 1000000, + sig: 'c'.repeat(128), + tags: [['p', recipient]], + } + + adapter.emit(WebSocketAdapterEvent.Event, event) + + expect(client.send).to.have.been.calledOnce + const sent = JSON.parse(client.send.firstCall.args[0]) + expect(sent[0]).to.equal('EVENT') + expect(sent[2]).to.deep.equal(event) + }) + + it('sends restricted-kind event to the authenticated author', () => { + const author = 'b'.repeat(64) + settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } }) + client.readyState = WebSocket.OPEN + adapter.addAuthenticatedPubkey(author) + adapter.onSubscribed('sub-1', [{ kinds: [4] }]) + + const event = { + id: 'a'.repeat(64), + pubkey: author, + kind: 4, + content: 'ciphertext', + created_at: 1000000, + sig: 'c'.repeat(128), + tags: [['p', 'd'.repeat(64)]], + } + + adapter.emit(WebSocketAdapterEvent.Event, event) + + expect(client.send).to.have.been.calledOnce + }) }) describe('onClientClose', () => { From 444d85ed82b9fe16d76c7e44549f4fdd5b7bb681 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 6/9] feat(nip42): advertise NIP-42 in supported_nips --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index df8dac50..f594f8c3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ 28, 33, 40, + 42, 44, 45, 50, From e21fc9e6c6bdb77e758316cbcc660612ff73c786 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 7/9] docs(nip42): document restrictedReads settings --- CONFIGURATION.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 23b61969..0df627a4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -184,6 +184,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta | nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. | | nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). | | nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). | +| nip42.restrictedReads.enabled | Enable NIP-42 auth-based read filtering. When enabled, events of the restricted kinds are only delivered to clients that have authenticated as the event's author or as a pubkey listed in the event's `p` tags. Applies to stored events (REQ), live broadcasts and COUNT queries. Subscriptions that exclusively target restricted kinds from unauthenticated clients are closed with an `auth-required:` reason. Defaults to false. | +| nip42.restrictedReads.kinds | List of event kinds (or `[min, max]` ranges) protected by auth-based read filtering. Defaults to `[4, 1059]` (NIP-04 encrypted direct messages and NIP-59 gift wraps). | | nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. | | nip50.enabled | Enable or disable NIP-50 full-text search. Defaults to false. When enabled, clients can include a `search` field in REQ filters to perform text queries against event content. Requires the GIN full-text index migration. | | nip50.language | PostgreSQL text-search configuration name. Defaults to `simple` (language-agnostic tokenization). Set to `english`, `spanish`, etc. for stemming support. See [PostgreSQL text search configurations](https://www.postgresql.org/docs/current/textsearch-configuration.html). **Note:** The GIN index migration is built with the `simple` configuration. If you change this value, you must manually rebuild the index: `DROP INDEX CONCURRENTLY events_content_fts_idx; CREATE INDEX CONCURRENTLY events_content_fts_idx ON events USING gin (to_tsvector('', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. | From 584c5c2701309297d7dbb243f00573ae6b239e03 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 14:36:04 +0530 Subject: [PATCH 8/9] chore(nip42): add changeset --- .changeset/nip42-restricted-reads.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/nip42-restricted-reads.md diff --git a/.changeset/nip42-restricted-reads.md b/.changeset/nip42-restricted-reads.md new file mode 100644 index 00000000..907d5301 --- /dev/null +++ b/.changeset/nip42-restricted-reads.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(nip42): enforce authentication on reads for restricted event kinds (encrypted DMs, gift wraps) across REQ, live broadcasts and COUNT From 740f1723e2a9960c074d8f8b757864e3667a63f3 Mon Sep 17 00:00:00 2001 From: anshumancanrock Date: Mon, 20 Jul 2026 20:29:35 +0530 Subject: [PATCH 9/9] refactor(nip42): rename read check to isClientAuthorizedToReadMention Address review feedback: the check is about who the event is directed to (author or p-tagged recipient), not the author alone. Also clarify why authenticated clients fall through isSubscriptionAuthRequired. --- src/utils/nip42.ts | 8 ++++++-- test/unit/utils/nip42.spec.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/utils/nip42.ts b/src/utils/nip42.ts index 1ea0f86e..a8dedf0e 100644 --- a/src/utils/nip42.ts +++ b/src/utils/nip42.ts @@ -24,7 +24,7 @@ export const getRestrictedReadKinds = (settings: Settings | undefined): (EventKi const isKindRestricted = (restrictedKinds: (EventKinds | EventKindsRange)[], kind: number): boolean => restrictedKinds.some(isEventKindOrRangeMatch({ kind } as Event)) -export const isClientAuthorizedToReadEvent = (event: Event, authenticatedPubkeys: ReadonlySet): boolean => { +export const isClientAuthorizedToReadMention = (event: Event, authenticatedPubkeys: ReadonlySet): boolean => { if (!authenticatedPubkeys.size) { return false } @@ -53,7 +53,7 @@ export const createReadAuthorizationGuard = ( return true } - return isClientAuthorizedToReadEvent(event, getAuthenticatedPubkeys()) + return isClientAuthorizedToReadMention(event, getAuthenticatedPubkeys()) } } @@ -80,6 +80,10 @@ export const isSubscriptionAuthRequired = ( return false } + // An authenticated client is allowed through: createReadAuthorizationGuard + // filters restricted events per-event, so they still only receive the ones + // they authored or are p-tagged in. Only an unauthenticated client, whose + // stream would always be empty, needs to be closed with auth-required. return getAuthenticatedPubkeys().size === 0 } diff --git a/test/unit/utils/nip42.spec.ts b/test/unit/utils/nip42.spec.ts index 440c5d3d..b0c20c72 100644 --- a/test/unit/utils/nip42.spec.ts +++ b/test/unit/utils/nip42.spec.ts @@ -4,7 +4,7 @@ import { createReadAuthorizationGuard, DEFAULT_RESTRICTED_READ_KINDS, getRestrictedReadKinds, - isClientAuthorizedToReadEvent, + isClientAuthorizedToReadMention, isCountAuthorized, isSubscriptionAuthRequired, } from '../../../src/utils/nip42' @@ -60,21 +60,21 @@ describe('nip42', () => { }) }) - describe('isClientAuthorizedToReadEvent', () => { + describe('isClientAuthorizedToReadMention', () => { it('returns false when no pubkeys are authenticated', () => { - expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set())).to.be.false + expect(isClientAuthorizedToReadMention(makeEvent(4, [['p', recipient]]), new Set())).to.be.false }) it('returns true for the event author', () => { - expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set([author]))).to.be.true + expect(isClientAuthorizedToReadMention(makeEvent(4, [['p', recipient]]), new Set([author]))).to.be.true }) it('returns true for a p-tagged recipient', () => { - expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set([recipient]))).to.be.true + expect(isClientAuthorizedToReadMention(makeEvent(4, [['p', recipient]]), new Set([recipient]))).to.be.true }) it('returns false for an unrelated authenticated pubkey', () => { - expect(isClientAuthorizedToReadEvent(makeEvent(4, [['p', recipient]]), new Set([stranger]))).to.be.false + expect(isClientAuthorizedToReadMention(makeEvent(4, [['p', recipient]]), new Set([stranger]))).to.be.false }) })