Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/nip42-restricted-reads.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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('<your_language>', event_content));` — otherwise the planner cannot use the index and queries fall back to sequential scans. |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
28,
33,
40,
42,
43,
44,
45,
Expand Down
8 changes: 8 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
nip43:
# NIP-43: invite-based relay membership. When enabled, only admitted members
# (users who claimed an invite code via a kind 28934 join request) may
Expand Down
11 changes: 11 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -321,6 +331,7 @@ export interface Settings {
limits?: Limits
mirroring?: Mirroring
nip05?: Nip05Settings
nip42?: Nip42Settings
nip43?: Nip43Settings
nip45?: Nip45Settings
nip50?: Nip50Settings
Expand Down
7 changes: 7 additions & 0 deletions src/adapters/web-socket-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/handlers/count-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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'
}
}
}
19 changes: 19 additions & 0 deletions src/handlers/subscribe-message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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),
Expand Down
116 changes: 116 additions & 0 deletions src/utils/nip42.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 isClientAuthorizedToReadMention = (event: Event, authenticatedPubkeys: ReadonlySet<Pubkey>): 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<Pubkey>,
): ((event: Event) => boolean) => {
const restrictedKinds = getRestrictedReadKinds(settings)
if (!restrictedKinds.length) {
return () => true
}

return (event: Event) => {
if (!isKindRestricted(restrictedKinds, event.kind)) {
return true
}

return isClientAuthorizedToReadMention(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<Pubkey>,
): boolean => {
const restrictedKinds = getRestrictedReadKinds(settings)
if (!restrictedKinds.length) {
return false
}

if (!filters.length || !filters.every(isFullyRestrictedFilter(restrictedKinds))) {
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
Comment thread
cameri marked this conversation as resolved.
}

// 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<Pubkey>,
): 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']),
)
}
88 changes: 88 additions & 0 deletions test/unit/adapters/web-socket-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading