Skip to content

Commit e172cce

Browse files
feat(nip42): restrict reads of encrypted kinds to authenticated recipients (#702)
* feat(nip42): add restrictedReads settings and defaults * feat(nip42): add read authorization helpers * feat(nip42): filter restricted kinds in REQ and close unauth-only subs * feat(nip42): require auth for restricted-kind COUNT * feat(nip42): gate live broadcasts on auth * feat(nip42): advertise NIP-42 in supported_nips * docs(nip42): document restrictedReads settings * chore(nip42): add changeset * 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.
1 parent 0bfa0b5 commit e172cce

13 files changed

Lines changed: 601 additions & 0 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
feat(nip42): enforce authentication on reads for restricted event kinds (encrypted DMs, gift wraps) across REQ, live broadcasts and COUNT

CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ The settings below are listed in alphabetical order by name. Please keep this ta
184184
| nip05.mode | NIP-05 verification mode: `enabled` requires verification, `passive` verifies without blocking, `disabled` does nothing. Defaults to `disabled`. |
185185
| nip05.verifyExpiration | Time in milliseconds before a successful NIP-05 verification expires and needs re-checking. Defaults to 604800000 (1 week). |
186186
| nip05.verifyUpdateFrequency | Minimum interval in milliseconds between re-verification attempts for a given author. Defaults to 86400000 (24 hours). |
187+
| 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. |
188+
| 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). |
187189
| nip45.enabled | Enable or disable NIP-45 COUNT handling. Defaults to true. |
188190
| 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. |
189191
| 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('<your_language>', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. |

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
28,
2323
33,
2424
40,
25+
42,
2526
43,
2627
44,
2728
45,

resources/default-settings.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ nip05:
6060
domainWhitelist: []
6161
# Block authors with NIP-05 at these domains
6262
domainBlacklist: []
63+
nip42:
64+
# Only deliver these kinds to clients authenticated (NIP-42) as the event's
65+
# author or a p-tagged recipient. Applies to REQ, live events and COUNT.
66+
restrictedReads:
67+
enabled: false
68+
kinds:
69+
- 4 # NIP-04 encrypted direct messages
70+
- 1059 # NIP-59 gift wraps (NIP-17 private DMs, Marmot welcomes)
6371
nip43:
6472
# NIP-43: invite-based relay membership. When enabled, only admitted members
6573
# (users who claimed an invite code via a kind 28934 join request) may

src/@types/settings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,16 @@ export interface WoTSettings {
303303
refreshIntervalHours: number
304304
}
305305

306+
export interface Nip42RestrictedReads {
307+
enabled: boolean
308+
// Restricted kinds/ranges. Defaults to [4, 1059] when unset.
309+
kinds?: (EventKinds | EventKindsRange)[]
310+
}
311+
312+
export interface Nip42Settings {
313+
restrictedReads?: Nip42RestrictedReads
314+
}
315+
306316
export interface Nip43Settings {
307317
enabled: boolean
308318
inviteCodeExpiry?: number
@@ -321,6 +331,7 @@ export interface Settings {
321331
limits?: Limits
322332
mirroring?: Mirroring
323333
nip05?: Nip05Settings
334+
nip42?: Nip42Settings
324335
nip43?: Nip43Settings
325336
nip45?: Nip45Settings
326337
nip50?: Nip50Settings

src/adapters/web-socket-adapter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createLogger } from '../factories/logger-factory'
1818
import { recordWebsocketConnectionClosed, recordWebsocketConnectionOpened } from '../telemetry/event-metrics'
1919
import { Event } from '../@types/event'
2020
import { getRemoteAddress } from '../utils/http'
21+
import { createReadAuthorizationGuard } from '../utils/nip42'
2122
import { IRateLimiter } from '../@types/utils'
2223
import { isEventMatchingFilter } from '../utils/event'
2324
import { messageSchema } from '../schemas/message-schema'
@@ -120,6 +121,12 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter
120121
}
121122

122123
public onSendEvent(event: Event): void {
124+
// NIP-42: don't broadcast restricted-kind events to unauthorized clients.
125+
const isReadAuthorized = createReadAuthorizationGuard(this.settings(), () => this.authenticatedPubkeys)
126+
if (!isReadAuthorized(event)) {
127+
return
128+
}
129+
123130
this.subscriptions.forEach((filters, subscriptionId) => {
124131
if (filters.map(isEventMatchingFilter).some((isMatch) => isMatch(event))) {
125132
logger('sending event to client %s: %o', this.clientId, event)

src/handlers/count-message-handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
99
import { WebSocketAdapterEvent } from '../constants/adapter'
1010
import { createLogger } from '../factories/logger-factory'
1111
import { createClosedMessage, createCountResultMessage } from '../utils/messages'
12+
import { isCountAuthorized } from '../utils/nip42'
1213

1314
const debug = createLogger('count-message-handler')
1415

@@ -63,5 +64,10 @@ export class CountMessageHandler implements IMessageHandler {
6364
) {
6465
return `Query ID too long: Query ID must be less than or equal to ${subscriptionLimits.maxSubscriptionIdLength}`
6566
}
67+
68+
// NIP-42: restricted-kind counts must be scoped to the client's own pubkeys.
69+
if (!isCountAuthorized(this.settings(), filters, () => this.webSocket.getAuthenticatedPubkeys())) {
70+
return 'auth-required: authentication is required to count these event kinds'
71+
}
6672
}
6773
}

src/handlers/subscribe-message-handler.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { anyPass, equals, isNil, map, omit, propSatisfies, uniqWith } from 'ramd
33
import { pipeline } from 'stream/promises'
44

55
import {
6+
createClosedMessage,
67
createEndOfStoredEventsNoticeMessage,
78
createNoticeMessage,
89
createOutgoingEventMessage,
910
} from '../utils/messages'
11+
import { createReadAuthorizationGuard, isSubscriptionAuthRequired } from '../utils/nip42'
1012
import { IAbortable, IMessageHandler } from '../@types/message-handlers'
1113
import { isEventMatchingFilter, isExpiredEvent, toNostrEvent } from '../utils/event'
1214
import { streamEach, streamEnd, streamFilter, streamMap } from '../utils/stream'
@@ -51,6 +53,16 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
5153
return
5254
}
5355

56+
// NIP-42: close restricted-only subs from unauthenticated clients.
57+
if (isSubscriptionAuthRequired(this.settings(), filters, () => this.webSocket.getAuthenticatedPubkeys())) {
58+
logger('subscription %s with %o rejected: auth required', subscriptionId, filters)
59+
this.webSocket.emit(
60+
WebSocketAdapterEvent.Message,
61+
createClosedMessage(subscriptionId, 'auth-required: authentication is required to request these event kinds'),
62+
)
63+
return
64+
}
65+
5466
this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, filters)
5567

5668
await this.fetchAndSend(subscriptionId, filters)
@@ -70,6 +82,12 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
7082
return true
7183
}
7284

85+
// NIP-42: drop restricted-kind events the client isn't authorized to read.
86+
const isReadAuthorized = createReadAuthorizationGuard(
87+
this.settings(),
88+
() => this.webSocket.getAuthenticatedPubkeys(),
89+
)
90+
7391
const findEvents = this.eventRepository.findByFilters(filters).stream()
7492

7593
// const abortableFindEvents = addAbortSignal(this.abortController.signal, findEvents)
@@ -80,6 +98,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable {
8098
streamFilter(propSatisfies(isNil, 'deleted_at')),
8199
streamMap(toNostrEvent),
82100
streamFilter(isTagUnexpired),
101+
streamFilter(isReadAuthorized),
83102
streamFilter(isSubscribedToEvent),
84103
streamEach(sendEvent),
85104
streamEnd(sendEOSE),

src/utils/nip42.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { EventKinds, EventTags } from '../constants/base'
2+
import { EventKindsRange, Settings } from '../@types/settings'
3+
import { Event } from '../@types/event'
4+
import { isEventKindOrRangeMatch } from './event'
5+
import { Pubkey } from '../@types/base'
6+
import { SubscriptionFilter } from '../@types/subscription'
7+
8+
// NIP-42: restricted kinds are only readable by the author or a p-tagged recipient.
9+
10+
export const DEFAULT_RESTRICTED_READ_KINDS: (EventKinds | EventKindsRange)[] = [
11+
EventKinds.ENCRYPTED_DIRECT_MESSAGE,
12+
EventKinds.GIFT_WRAP,
13+
]
14+
15+
export const getRestrictedReadKinds = (settings: Settings | undefined): (EventKinds | EventKindsRange)[] => {
16+
const restrictedReads = settings?.nip42?.restrictedReads
17+
if (!restrictedReads?.enabled) {
18+
return []
19+
}
20+
21+
return Array.isArray(restrictedReads.kinds) ? restrictedReads.kinds : DEFAULT_RESTRICTED_READ_KINDS
22+
}
23+
24+
const isKindRestricted = (restrictedKinds: (EventKinds | EventKindsRange)[], kind: number): boolean =>
25+
restrictedKinds.some(isEventKindOrRangeMatch({ kind } as Event))
26+
27+
export const isClientAuthorizedToReadMention = (event: Event, authenticatedPubkeys: ReadonlySet<Pubkey>): boolean => {
28+
if (!authenticatedPubkeys.size) {
29+
return false
30+
}
31+
32+
if (authenticatedPubkeys.has(event.pubkey)) {
33+
return true
34+
}
35+
36+
return event.tags.some(
37+
(tag) => tag.length >= 2 && tag[0] === EventTags.Pubkey && authenticatedPubkeys.has(tag[1]),
38+
)
39+
}
40+
41+
// getAuthenticatedPubkeys is only read for restricted events, so the guard is free when disabled.
42+
export const createReadAuthorizationGuard = (
43+
settings: Settings | undefined,
44+
getAuthenticatedPubkeys: () => ReadonlySet<Pubkey>,
45+
): ((event: Event) => boolean) => {
46+
const restrictedKinds = getRestrictedReadKinds(settings)
47+
if (!restrictedKinds.length) {
48+
return () => true
49+
}
50+
51+
return (event: Event) => {
52+
if (!isKindRestricted(restrictedKinds, event.kind)) {
53+
return true
54+
}
55+
56+
return isClientAuthorizedToReadMention(event, getAuthenticatedPubkeys())
57+
}
58+
}
59+
60+
const isFullyRestrictedFilter =
61+
(restrictedKinds: (EventKinds | EventKindsRange)[]) =>
62+
(filter: SubscriptionFilter): boolean =>
63+
Array.isArray(filter.kinds) &&
64+
filter.kinds.length > 0 &&
65+
filter.kinds.every((kind) => isKindRestricted(restrictedKinds, kind))
66+
67+
// A sub that only asks for restricted kinds can never return anything to an
68+
// unauthenticated client, so we close it instead of serving an empty stream.
69+
export const isSubscriptionAuthRequired = (
70+
settings: Settings | undefined,
71+
filters: SubscriptionFilter[],
72+
getAuthenticatedPubkeys: () => ReadonlySet<Pubkey>,
73+
): boolean => {
74+
const restrictedKinds = getRestrictedReadKinds(settings)
75+
if (!restrictedKinds.length) {
76+
return false
77+
}
78+
79+
if (!filters.length || !filters.every(isFullyRestrictedFilter(restrictedKinds))) {
80+
return false
81+
}
82+
83+
// An authenticated client is allowed through: createReadAuthorizationGuard
84+
// filters restricted events per-event, so they still only receive the ones
85+
// they authored or are p-tagged in. Only an unauthenticated client, whose
86+
// stream would always be empty, needs to be closed with auth-required.
87+
return getAuthenticatedPubkeys().size === 0
88+
}
89+
90+
// COUNT can't be filtered per event, so a restricted-kind filter must be
91+
// scoped to the client's own pubkeys via authors/#p.
92+
export const isCountAuthorized = (
93+
settings: Settings | undefined,
94+
filters: SubscriptionFilter[],
95+
getAuthenticatedPubkeys: () => ReadonlySet<Pubkey>,
96+
): boolean => {
97+
const restrictedKinds = getRestrictedReadKinds(settings)
98+
if (!restrictedKinds.length) {
99+
return true
100+
}
101+
102+
const restrictedFilters = filters.filter(
103+
(filter) => Array.isArray(filter.kinds) && filter.kinds.some((kind) => isKindRestricted(restrictedKinds, kind)),
104+
)
105+
if (!restrictedFilters.length) {
106+
return true
107+
}
108+
109+
const authenticatedPubkeys = getAuthenticatedPubkeys()
110+
const isScopedToClient = (values?: Pubkey[]) =>
111+
Array.isArray(values) && values.length > 0 && values.every((value) => authenticatedPubkeys.has(value))
112+
113+
return restrictedFilters.every(
114+
(filter) => isScopedToClient(filter.authors) || isScopedToClient(filter['#p']),
115+
)
116+
}

test/unit/adapters/web-socket-adapter.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,94 @@ describe('WebSocketAdapter', () => {
293293

294294
expect(client.send).not.to.have.been.called
295295
})
296+
297+
it('does not send restricted-kind event to unauthenticated client', () => {
298+
settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } })
299+
client.readyState = WebSocket.OPEN
300+
adapter.onSubscribed('sub-1', [{ kinds: [1059] }])
301+
302+
const event = {
303+
id: 'a'.repeat(64),
304+
pubkey: 'b'.repeat(64),
305+
kind: 1059,
306+
content: 'sealed',
307+
created_at: 1000000,
308+
sig: 'c'.repeat(128),
309+
tags: [['p', 'd'.repeat(64)]],
310+
}
311+
312+
adapter.emit(WebSocketAdapterEvent.Event, event)
313+
314+
expect(client.send).not.to.have.been.called
315+
})
316+
317+
it('does not send restricted-kind event to a client authenticated as somebody else', () => {
318+
settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } })
319+
client.readyState = WebSocket.OPEN
320+
adapter.addAuthenticatedPubkey('e'.repeat(64))
321+
adapter.onSubscribed('sub-1', [{ kinds: [1059] }])
322+
323+
const event = {
324+
id: 'a'.repeat(64),
325+
pubkey: 'b'.repeat(64),
326+
kind: 1059,
327+
content: 'sealed',
328+
created_at: 1000000,
329+
sig: 'c'.repeat(128),
330+
tags: [['p', 'd'.repeat(64)]],
331+
}
332+
333+
adapter.emit(WebSocketAdapterEvent.Event, event)
334+
335+
expect(client.send).not.to.have.been.called
336+
})
337+
338+
it('sends restricted-kind event to the authenticated recipient', () => {
339+
const recipient = 'd'.repeat(64)
340+
settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } })
341+
client.readyState = WebSocket.OPEN
342+
adapter.addAuthenticatedPubkey(recipient)
343+
adapter.onSubscribed('sub-1', [{ kinds: [1059] }])
344+
345+
const event = {
346+
id: 'a'.repeat(64),
347+
pubkey: 'b'.repeat(64),
348+
kind: 1059,
349+
content: 'sealed',
350+
created_at: 1000000,
351+
sig: 'c'.repeat(128),
352+
tags: [['p', recipient]],
353+
}
354+
355+
adapter.emit(WebSocketAdapterEvent.Event, event)
356+
357+
expect(client.send).to.have.been.calledOnce
358+
const sent = JSON.parse(client.send.firstCall.args[0])
359+
expect(sent[0]).to.equal('EVENT')
360+
expect(sent[2]).to.deep.equal(event)
361+
})
362+
363+
it('sends restricted-kind event to the authenticated author', () => {
364+
const author = 'b'.repeat(64)
365+
settingsFactory.returns({ nip42: { restrictedReads: { enabled: true } } })
366+
client.readyState = WebSocket.OPEN
367+
adapter.addAuthenticatedPubkey(author)
368+
adapter.onSubscribed('sub-1', [{ kinds: [4] }])
369+
370+
const event = {
371+
id: 'a'.repeat(64),
372+
pubkey: author,
373+
kind: 4,
374+
content: 'ciphertext',
375+
created_at: 1000000,
376+
sig: 'c'.repeat(128),
377+
tags: [['p', 'd'.repeat(64)]],
378+
}
379+
380+
adapter.emit(WebSocketAdapterEvent.Event, event)
381+
382+
expect(client.send).to.have.been.calledOnce
383+
})
296384
})
297385

298386
describe('onClientClose', () => {

0 commit comments

Comments
 (0)