Skip to content

Commit 529ccbf

Browse files
fix(nip77): address review findings in negentropy sessions
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
1 parent 3d33caa commit 529ccbf

12 files changed

Lines changed: 535 additions & 74 deletions

‎CONFIGURATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ The settings below are listed in alphabetical order by name. Please keep this ta
232232
| nip66.timeouts.nip11Ms | NIP-11 fetch timeout in milliseconds. Defaults to 10000. |
233233
| nip66.timeouts.tlsMs | TLS probe timeout in milliseconds. Defaults to 10000. |
234234
| nip66.timeouts.wsRttMs | WebSocket open RTT probe timeout in milliseconds. Defaults to 10000. |
235-
| nip77.enabled | Enable NIP-77 (Negentropy) set-reconciliation sync, so clients and other relays can find which events differ without downloading them all. Defaults to false. Only answers when `eventStore.backend` is `postgres`; other backends bring their own negentropy implementation. Each open session holds the ids of every event matching its filter in memory, so size the limits below for your hardware. |
235+
| nip77.enabled | Enable NIP-77 (Negentropy) set-reconciliation sync, so clients and other relays can find which events differ without downloading them all. Defaults to false. Only answers when `eventStore.backend` is `postgres`; other backends bring their own negentropy implementation. When `nip42.restrictedReads` is enabled, reconciliation would list the ids of restricted events, so a `NEG-OPEN` filter that could match one (it names a restricted kind or no kinds at all) is only accepted when scoped by `authors` or `#p` to pubkeys the client has authenticated as, and is re-checked on every `NEG-MSG`; otherwise the session is closed with `NEG-ERR auth-required:`. Each open session holds the ids of every event matching its filter in memory, so size the limits below for your hardware. |
236236
| nip77.maxEventsPerSession | Most events one session may cover. A `NEG-OPEN` whose filter matches more is rejected with `NEG-ERR blocked:` (clients should split the sync into `since`/`until` windows). The filter's `limit` is ignored, since reconciliation needs the whole set. Defaults to 100000. |
237237
| nip77.maxSessionsPerConnection | Concurrent negentropy sessions one websocket connection may hold; opening an id that is already open replaces it. Further `NEG-OPEN`s are rejected with `NEG-ERR blocked:`. Defaults to 3. |
238238
| nip77.sessionIdleTimeoutMs | A session that receives no `NEG-MSG` for this many milliseconds is closed with `NEG-ERR closed:`. Defaults to 60000. |

‎src/@types/negentropy.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,39 @@
11
import { Negentropy } from '../utils/negentropy'
2-
import { SubscriptionId } from './subscription'
2+
import { SubscriptionFilter, SubscriptionId } from './subscription'
33

44
/** Proves a caller still owns a subscription id after awaiting; see `INegentropySessions.reserve`. */
55
export type NegentropySessionToken = symbol
66

7+
export interface NegentropySession {
8+
negentropy: Negentropy
9+
/** The filter the session was opened with, so access can be re-checked on every message. */
10+
filter: SubscriptionFilter
11+
}
12+
713
export interface INegentropySessions {
814
/**
915
* Claims `subscriptionId` for a new session, closing any session already open under that id.
10-
* Returns undefined when the connection already holds `maxSessions` other sessions.
16+
* Returns undefined when the connection already holds `maxLimit` other sessions, or already has
17+
* `maxLimit` event reads in flight -- including reads of requests that have since been replaced,
18+
* which keep running until `endRead` is called. The caller must call `endRead` for a token it got.
1119
*/
12-
reserve(subscriptionId: SubscriptionId, maxSessions: number): NegentropySessionToken | undefined
20+
reserve(subscriptionId: SubscriptionId, maxLimit: number): NegentropySessionToken | undefined
21+
/** Marks the event read started for a reservation as finished, successfully or not. */
22+
endRead(token: NegentropySessionToken): void
23+
/** Whether `token` is still the reservation for `subscriptionId`, i.e. nobody replaced or closed it. */
24+
isCurrent(subscriptionId: SubscriptionId, token: NegentropySessionToken): boolean
1325
/**
1426
* Turns a reservation into a live session. Returns false when the reservation was closed or
1527
* replaced while the caller was busy (e.g. reading events), in which case nothing is stored.
1628
*/
1729
activate(
1830
subscriptionId: SubscriptionId,
1931
token: NegentropySessionToken,
20-
negentropy: Negentropy,
32+
session: NegentropySession,
2133
idleTimeoutMs: number,
2234
): boolean
2335
/** The live session for an id, with its idle timer restarted. */
24-
touch(subscriptionId: SubscriptionId): Negentropy | undefined
36+
touch(subscriptionId: SubscriptionId): NegentropySession | undefined
2537
/** Drops a reservation that never became a session; a no-op if it has since been replaced. */
2638
release(subscriptionId: SubscriptionId, token: NegentropySessionToken): void
2739
close(subscriptionId: SubscriptionId): boolean

‎src/handlers/neg-msg-message-handler.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Settings } from '../@types/settings'
66
import { WebSocketAdapterEvent } from '../constants/adapter'
77
import { createLogger } from '../factories/logger-factory'
88
import { isNip77Active } from '../utils/nip77'
9+
import { isReconciliationAuthorized } from '../utils/nip42'
910

1011
const logger = createLogger('neg-msg-message-handler')
1112

@@ -26,14 +27,23 @@ export class NegMsgMessageHandler implements IMessageHandler {
2627
return
2728
}
2829

29-
const negentropy = sessions.touch(subscriptionId)
30-
if (!negentropy) {
30+
const session = sessions.touch(subscriptionId)
31+
if (!session) {
3132
this.reply(createNegErrMessage(subscriptionId, 'closed: unknown negentropy subscription'))
3233
return
3334
}
3435

36+
// Access can lapse while a session is open (e.g. NIP-42 auth expiring), so check it every time.
37+
if (!isReconciliationAuthorized(this.settings(), session.filter, () => this.webSocket.getAuthenticatedPubkeys())) {
38+
sessions.close(subscriptionId)
39+
this.reply(
40+
createNegErrMessage(subscriptionId, 'auth-required: authentication is required to sync these event kinds'),
41+
)
42+
return
43+
}
44+
3545
try {
36-
const { output } = negentropy.reconcile(Buffer.from(message[2], 'hex'))
46+
const { output } = session.negentropy.reconcile(Buffer.from(message[2], 'hex'))
3747

3848
this.reply(createNegMsgMessage(subscriptionId, Buffer.from(output as Uint8Array).toString('hex')))
3949
} catch (error) {

‎src/handlers/neg-open-message-handler.ts‎

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { IWebSocketAdapter } from '../@types/adapters'
22
import { createNegErrMessage, createNegMsgMessage } from '../utils/messages'
33
import { getNip77Limits, isNip77Active, NEGENTROPY_FRAME_SIZE_LIMIT } from '../utils/nip77'
4+
import { INegentropySessions } from '../@types/negentropy'
45
import { Negentropy, NegentropyStorageVector } from '../utils/negentropy'
5-
import { IEventRepository } from '../@types/repositories'
6+
import { IEventRepository, ReconciliationItem } from '../@types/repositories'
67
import { IMessageHandler } from '../@types/message-handlers'
78
import { NegOpenMessage, OutgoingMessage } from '../@types/messages'
89
import { Settings } from '../@types/settings'
910
import { SubscriptionFilter, SubscriptionId } from '../@types/subscription'
1011
import { WebSocketAdapterEvent } from '../constants/adapter'
1112
import { countFilterValues } from '../utils/filter'
1213
import { createLogger } from '../factories/logger-factory'
13-
import { isCountAuthorized } from '../utils/nip42'
14+
import { isReconciliationAuthorized } from '../utils/nip42'
1415

1516
const logger = createLogger('neg-open-message-handler')
1617

@@ -31,41 +32,62 @@ export class NegOpenMessageHandler implements IMessageHandler {
3132
const initialMessage = Buffer.from(message[3], 'hex')
3233

3334
const settings = this.settings()
35+
const sessions = this.webSocket.getNegentropySessions()
36+
3437
if (!isNip77Active(settings)) {
35-
this.reply(createNegErrMessage(subscriptionId, 'blocked: NIP-77 is disabled by relay configuration'))
38+
this.reject(sessions, subscriptionId, 'blocked: NIP-77 is disabled by relay configuration')
3639
return
3740
}
3841

3942
const rejection = this.getRejection(subscriptionId, filter, initialMessage)
4043
if (rejection) {
4144
logger('NEG-OPEN %s with %o rejected: %s', subscriptionId, filter, rejection)
42-
this.reply(createNegErrMessage(subscriptionId, rejection))
45+
this.reject(sessions, subscriptionId, rejection)
4346
return
4447
}
4548

4649
const { maxEventsPerSession, maxSessionsPerConnection, sessionIdleTimeoutMs } = getNip77Limits(settings)
47-
const sessions = this.webSocket.getNegentropySessions()
4850

4951
const token = sessions.reserve(subscriptionId, maxSessionsPerConnection)
5052
if (!token) {
51-
this.reply(
52-
createNegErrMessage(
53-
subscriptionId,
54-
`blocked: too many negentropy sessions on this connection (max ${maxSessionsPerConnection})`,
55-
),
53+
this.reject(
54+
sessions,
55+
subscriptionId,
56+
`blocked: too many negentropy sessions or reads in progress on this connection (max ${maxSessionsPerConnection})`,
5657
)
5758
return
5859
}
5960

61+
let items: ReconciliationItem[]
6062
try {
6163
// One row past the limit tells us the set is too big without loading all of it.
62-
const items = await this.eventRepository.findReconciliationItems(filter, maxEventsPerSession + 1)
63-
if (items.length > maxEventsPerSession) {
64+
items = await this.eventRepository.findReconciliationItems(filter, maxEventsPerSession + 1)
65+
} catch (error) {
66+
if (sessions.isCurrent(subscriptionId, token)) {
6467
sessions.release(subscriptionId, token)
65-
this.reply(createNegErrMessage(subscriptionId, 'blocked: this query is too big', maxEventsPerSession))
66-
return
68+
logger('NEG-OPEN %s failed: %o', subscriptionId, error)
69+
// Keep this generic so internal errors are not leaked to clients.
70+
this.reply(createNegErrMessage(subscriptionId, 'error: unable to process negentropy request'))
6771
}
72+
return
73+
} finally {
74+
sessions.endRead(token)
75+
}
76+
77+
// The client may have closed or replaced this NEG-OPEN while events were being read. Then the
78+
// newer request owns the id: say nothing, since an error here would tell the client to drop it.
79+
if (!sessions.isCurrent(subscriptionId, token)) {
80+
return
81+
}
6882

83+
// Nothing below awaits, so ownership cannot change before the reply.
84+
if (items.length > maxEventsPerSession) {
85+
sessions.release(subscriptionId, token)
86+
this.reply(createNegErrMessage(subscriptionId, 'blocked: this query is too big', maxEventsPerSession))
87+
return
88+
}
89+
90+
try {
6991
const storage = new NegentropyStorageVector()
7092
for (const item of items) {
7193
storage.insert(item.event_created_at, item.event_id)
@@ -85,16 +107,11 @@ export class NegOpenMessageHandler implements IMessageHandler {
85107
return
86108
}
87109

88-
if (!sessions.activate(subscriptionId, token, negentropy, sessionIdleTimeoutMs)) {
89-
// Closed or replaced by the client while we were reading events: stay silent.
90-
return
91-
}
92-
110+
sessions.activate(subscriptionId, token, { negentropy, filter }, sessionIdleTimeoutMs)
93111
this.reply(createNegMsgMessage(subscriptionId, Buffer.from(output).toString('hex')))
94112
} catch (error) {
95113
sessions.release(subscriptionId, token)
96114
logger('NEG-OPEN %s failed: %o', subscriptionId, error)
97-
// Keep this generic so internal errors are not leaked to clients.
98115
this.reply(createNegErrMessage(subscriptionId, 'error: unable to process negentropy request'))
99116
}
100117
}
@@ -123,13 +140,20 @@ export class NegOpenMessageHandler implements IMessageHandler {
123140
return 'invalid: malformed negentropy message'
124141
}
125142

126-
// NIP-42: like COUNT, reconciling reveals which events exist, so restricted kinds must be
127-
// scoped to the client's own pubkeys.
128-
if (!isCountAuthorized(settings, [filter], () => this.webSocket.getAuthenticatedPubkeys())) {
143+
// NIP-42: reconciling reveals the id of every event the filter matches, so a filter that could
144+
// match a restricted kind must be scoped to the client's own pubkeys.
145+
if (!isReconciliationAuthorized(settings, filter, () => this.webSocket.getAuthenticatedPubkeys())) {
129146
return 'auth-required: authentication is required to sync these event kinds'
130147
}
131148
}
132149

150+
// NIP-77: after a NEG-ERR the subscription is closed, so a rejected NEG-OPEN must also end any
151+
// session the client already had under that id; otherwise it keeps serving, and holding a slot.
152+
private reject(sessions: INegentropySessions, subscriptionId: SubscriptionId, reason: string): void {
153+
sessions.close(subscriptionId)
154+
this.reply(createNegErrMessage(subscriptionId, reason))
155+
}
156+
133157
private reply(message: OutgoingMessage): void {
134158
this.webSocket.emit(WebSocketAdapterEvent.Message, message)
135159
}

‎src/utils/negentropy-sessions.ts‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,74 @@
1-
import { INegentropySessions, NegentropySessionToken } from '../@types/negentropy'
2-
import { Negentropy } from './negentropy'
1+
import { INegentropySessions, NegentropySession, NegentropySessionToken } from '../@types/negentropy'
32
import { SubscriptionId } from '../@types/subscription'
43

54
interface Entry {
65
token: NegentropySessionToken
7-
negentropy?: Negentropy
6+
session?: NegentropySession
87
idleTimeoutMs?: number
98
idleTimer?: NodeJS.Timeout
109
}
1110

1211
/** The NIP-77 sessions of one websocket connection. Ids are a separate namespace from REQ ids. */
1312
export class NegentropySessions implements INegentropySessions {
1413
private readonly entries = new Map<SubscriptionId, Entry>()
14+
// Event reads still running. A replaced request's read is not cancelled, so it stays counted here
15+
// until it finishes; otherwise repeating NEG-OPEN on one id would stack up unbounded queries.
16+
private readonly pendingReads = new Set<NegentropySessionToken>()
1517

1618
public constructor(private readonly onIdleTimeout: (subscriptionId: SubscriptionId) => void) {}
1719

18-
public reserve(subscriptionId: SubscriptionId, maxSessions: number): NegentropySessionToken | undefined {
20+
public reserve(subscriptionId: SubscriptionId, maxLimit: number): NegentropySessionToken | undefined {
1921
// NIP-77: opening an id that is already open closes the old session first.
2022
this.close(subscriptionId)
2123

22-
if (this.entries.size >= maxSessions) {
24+
if (this.entries.size >= maxLimit || this.pendingReads.size >= maxLimit) {
2325
return undefined
2426
}
2527

2628
const token = Symbol(subscriptionId)
2729
this.entries.set(subscriptionId, { token })
30+
this.pendingReads.add(token)
2831

2932
return token
3033
}
3134

35+
public endRead(token: NegentropySessionToken): void {
36+
this.pendingReads.delete(token)
37+
}
38+
39+
public isCurrent(subscriptionId: SubscriptionId, token: NegentropySessionToken): boolean {
40+
return this.entries.get(subscriptionId)?.token === token
41+
}
42+
3243
public activate(
3344
subscriptionId: SubscriptionId,
3445
token: NegentropySessionToken,
35-
negentropy: Negentropy,
46+
session: NegentropySession,
3647
idleTimeoutMs: number,
3748
): boolean {
3849
const entry = this.entries.get(subscriptionId)
3950
if (!entry || entry.token !== token) {
4051
return false
4152
}
4253

43-
entry.negentropy = negentropy
54+
// Activating means the read is over.
55+
this.pendingReads.delete(token)
56+
entry.session = session
4457
entry.idleTimeoutMs = idleTimeoutMs
4558
this.startIdleTimer(subscriptionId, entry)
4659

4760
return true
4861
}
4962

50-
public touch(subscriptionId: SubscriptionId): Negentropy | undefined {
63+
public touch(subscriptionId: SubscriptionId): NegentropySession | undefined {
5164
const entry = this.entries.get(subscriptionId)
52-
if (!entry?.negentropy) {
65+
if (!entry?.session) {
5366
return undefined
5467
}
5568

5669
this.startIdleTimer(subscriptionId, entry)
5770

58-
return entry.negentropy
71+
return entry.session
5972
}
6073

6174
public release(subscriptionId: SubscriptionId, token: NegentropySessionToken): void {
@@ -83,7 +96,7 @@ export class NegentropySessions implements INegentropySessions {
8396
}
8497

8598
public getSessionIds(): SubscriptionId[] {
86-
return [...this.entries.entries()].filter(([, entry]) => entry.negentropy).map(([subscriptionId]) => subscriptionId)
99+
return [...this.entries.entries()].filter(([, entry]) => entry.session).map(([subscriptionId]) => subscriptionId)
87100
}
88101

89102
private startIdleTimer(subscriptionId: SubscriptionId, entry: Entry): void {

‎src/utils/nip42.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,32 @@ export const isSubscriptionAuthRequired = (
8989
return getAuthenticatedPubkeys().size === 0
9090
}
9191

92+
// NIP-77 reconciliation, like COUNT, can't be filtered per event: it reveals the id of every event the
93+
// filter matches. REQ hides restricted events per event, so a filter that *could* match one -- one
94+
// naming a restricted kind or naming no kinds at all -- must be scoped to the client's own pubkeys.
95+
export const isReconciliationAuthorized = (
96+
settings: Settings | undefined,
97+
filter: SubscriptionFilter,
98+
getAuthenticatedPubkeys: () => ReadonlySet<Pubkey>,
99+
): boolean => {
100+
const restrictedKinds = getRestrictedReadKinds(settings)
101+
if (!restrictedKinds.length) {
102+
return true
103+
}
104+
105+
const mayMatchRestricted =
106+
!Array.isArray(filter.kinds) || filter.kinds.some((kind) => isKindRestricted(restrictedKinds, kind))
107+
if (!mayMatchRestricted) {
108+
return true
109+
}
110+
111+
const authenticatedPubkeys = getAuthenticatedPubkeys()
112+
const isScopedToClient = (values?: Pubkey[]) =>
113+
Array.isArray(values) && values.length > 0 && values.every((value) => authenticatedPubkeys.has(value))
114+
115+
return isScopedToClient(filter.authors) || isScopedToClient(filter['#p'])
116+
}
117+
92118
// COUNT can't be filtered per event, so a restricted-kind filter must be
93119
// scoped to the client's own pubkeys via authors/#p.
94120
export const isCountAuthorized = (

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,12 @@ describe('WebSocketAdapter', () => {
988988

989989
const openSession = (id: string, idleTimeoutMs = 60000) => {
990990
const sessions = adapter.getNegentropySessions()
991-
sessions.activate(id, sessions.reserve(id, 3) as symbol, emptyStorage(), idleTimeoutMs)
991+
sessions.activate(
992+
id,
993+
sessions.reserve(id, 3) as symbol,
994+
{ negentropy: emptyStorage(), filter: {} },
995+
idleTimeoutMs,
996+
)
992997
}
993998

994999
const sentMessages = () => client.send.getCalls().map((call: any) => JSON.parse(call.args[0]))

‎test/unit/handlers/neg-close-message-handler.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe('NegCloseMessageHandler', () => {
1717
const openSession = (id: string) => {
1818
const storage = new NegentropyStorageVector()
1919
storage.seal()
20-
sessions.activate(id, sessions.reserve(id, 3) as symbol, new Negentropy(storage), 60000)
20+
sessions.activate(id, sessions.reserve(id, 3) as symbol, { negentropy: new Negentropy(storage), filter: {} }, 60000)
2121
}
2222

2323
beforeEach(() => {

0 commit comments

Comments
 (0)